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 0000000000000000000000000000000000000000..c7a7ed78442b794fe370eb11c4815b8b71626015 --- /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 a0164f94774dd3b8921e1e6ef0aac88aaec0fd23..3873bf5bd0e35df7b781beb4155262477af05995 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 ec88619565b10bb91760b36c50ef199fbe8cd61e..8bea0c9075f3f692b84680b50c0aa268e19ecedf 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 0000000000000000000000000000000000000000..35e89dd672982213bf379b23f852e968abc8eb4f --- /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 aa799201ba057e7cfaf5bca95655e6216dff9abf..69cca9a7062b3ca8b74de8a20019cf2de465c271 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 0000000000000000000000000000000000000000..2daccca2887e8c8d9d80647ae9d4bd1b28613e13 --- /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 0000000000000000000000000000000000000000..6d224aa687100b6f67312a819afd84c0f47d1478 --- /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 7d0c7c3e0f378a1b57a0bf70342ae7ac3ac4cb4a..79839f120e3960896ef35cb86a575822440f7479 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 0000000000000000000000000000000000000000..9363eb877b6b064a3c4a063b0125413592f605aa --- /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 0000000000000000000000000000000000000000..0ae63deccb5bcc581d618e91ec9f505943cc7245 --- /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 e8fe8153bda70f679dfec460bc9c3b9b006c912e..505a411b72a4a6fd56f256415082438436a5321a 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 0000000000000000000000000000000000000000..6c5051d61d2f8a78ee8d61927e2b49839ae4b148 --- /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 762118e6b445e628b92624cb9a53f5269ad5e352..8c8c17858e7ee666991ca2efa39cc434244e521b 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 0000000000000000000000000000000000000000..2963f999eaf62520de711a7684963b46df83f3e7 --- /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, + }), + ); + }); + }); +});