From f5fd8b956970da6920d81741aa1236b1c43eb742 Mon Sep 17 00:00:00 2001 From: Gabriel Tira Date: Wed, 5 Nov 2025 16:52:03 +0100 Subject: [PATCH 1/6] Implemented useShowImageReference logic --- .../src/PhotoCapture/PhotoCapture.tsx | 5 ++ .../src/hooks/useShowImageReference.ts | 48 ++++++++++ .../test/hooks/useShowImageReference.test.ts | 90 +++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 packages/inspection-capture-web/src/hooks/useShowImageReference.ts create mode 100644 packages/inspection-capture-web/test/hooks/useShowImageReference.test.ts diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx index b2c7d531c..3a77a077c 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx @@ -43,6 +43,7 @@ import { usePhotoCaptureSightTutorial, useInspectionComplete, } from './hooks'; +import { useShowImageReference } from '../hooks/useShowImageReference'; // import { SessionTimeTrackerDemo } from '../components/SessionTimeTrackerDemo'; /** @@ -210,6 +211,10 @@ export function PhotoCapture({ enableSightTutorial, }); const { showSightTutorial, toggleSightTutorial } = usePhotoCaptureSightTutorial(); + useShowImageReference({ + sight: sightState.selectedSight, + toggleSightTutorial, + }); const { showSightGuidelines, handleDisableSightGuidelines } = usePhotoCaptureSightGuidelines({ enableSightGuidelines, }); diff --git a/packages/inspection-capture-web/src/hooks/useShowImageReference.ts b/packages/inspection-capture-web/src/hooks/useShowImageReference.ts new file mode 100644 index 000000000..39195bfe8 --- /dev/null +++ b/packages/inspection-capture-web/src/hooks/useShowImageReference.ts @@ -0,0 +1,48 @@ +import { useMonkState, useObjectMemo } from '@monkvision/common'; +import { CAR_COVERAGE_COMPLIANCE_ISSUES, Sight } from '@monkvision/types'; +import { useEffect, useMemo, useState } from 'react'; + +export interface UseTutorialForNonCompliantParams { + /** The current sight. */ + sight: Sight; + /** Callback called if an image is not compliant. */ + toggleSightTutorial: () => void; +} + +/* Hook to display the tutorial if the current sight is for non-compliant. */ +export function useShowImageReference({ + sight, + toggleSightTutorial, +}: UseTutorialForNonCompliantParams) { + const { state } = useMonkState(); + const [isMounted, setIsMounted] = useState(false); + + /* Allow time for the Sights Slider animation to finish */ + useEffect(() => { + setTimeout(() => setIsMounted(true), 1000); + }, []); + + const hasCarCoverageComplianceIssues = useMemo(() => { + return state.images.some((image) => { + return ( + image.sightId === sight.id && + image.complianceIssues && + image.complianceIssues.some((issue) => CAR_COVERAGE_COMPLIANCE_ISSUES.includes(issue)) + ); + }); + }, [sight, state.images]); + + useEffect(() => { + if (!isMounted) { + return; + } + + if (hasCarCoverageComplianceIssues) { + toggleSightTutorial(); + } + }, [isMounted, hasCarCoverageComplianceIssues]); + + return useObjectMemo({ + hasCarCoverageComplianceIssues, + }); +} diff --git a/packages/inspection-capture-web/test/hooks/useShowImageReference.test.ts b/packages/inspection-capture-web/test/hooks/useShowImageReference.test.ts new file mode 100644 index 000000000..4cb4a89b3 --- /dev/null +++ b/packages/inspection-capture-web/test/hooks/useShowImageReference.test.ts @@ -0,0 +1,90 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useShowImageReference } from '../../src/hooks/useShowImageReference'; +import { useMonkState } from '@monkvision/common'; +import { ComplianceIssue, Sight, SightCategory, VehicleModel } from '@monkvision/types'; +import { act } from '@testing-library/react'; + +const waitForSyntheticMount = async (): Promise => { + await new Promise((resolve) => { + setTimeout(resolve, 1100); + }); +}; + +const nonCompliantSight: Sight = { + id: 'sight-1', + category: SightCategory.EXTERIOR, + label: 'Front View', + overlay: 'front_view_overlay.png', + tasks: [], + vehicle: VehicleModel.ALL, +}; + +const compliantSight: Sight = { + id: 'sight-2', + category: SightCategory.EXTERIOR, + label: 'Front View', + overlay: 'front_view_overlay.png', + tasks: [], + vehicle: VehicleModel.ALL, +}; + +const state = { + images: [ + { sightId: nonCompliantSight.id, id: 'id-1', complianceIssues: [ComplianceIssue.NO_VEHICLE] }, + { sightId: compliantSight.id, id: 'id-2' }, + { sightId: 'sight-3', id: 'id-3', complianceIssues: [ComplianceIssue.NO_VEHICLE] }, + ], +}; + +describe('useShowImageReference hook', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should not fire unless component was mounted', async () => { + const toggleSightTutorial = jest.fn(); + (useMonkState as jest.Mock).mockImplementation(() => ({ state })); + const { result, unmount } = renderHook(useShowImageReference, { + initialProps: { + sight: nonCompliantSight, + toggleSightTutorial, + }, + }); + + expect(result.current.hasCarCoverageComplianceIssues).toBe(true); + expect(toggleSightTutorial.mock.calls.length).toBe(0); + + await act(waitForSyntheticMount); + + expect(toggleSightTutorial.mock.calls.length).toBe(1); + + unmount(); + }); + + it('should trigger only if current sight has Car Coverage compliance issues', async () => { + const toggleSightTutorial = jest.fn(); + (useMonkState as jest.Mock).mockImplementation(() => ({ state })); + + const { result, unmount, rerender } = renderHook(useShowImageReference, { + initialProps: { + sight: nonCompliantSight, + toggleSightTutorial, + }, + }); + + await act(waitForSyntheticMount); + + expect(result.current.hasCarCoverageComplianceIssues).toBe(true); + expect(toggleSightTutorial.mock.calls.length).toBe(1); + + rerender({ + sight: compliantSight, + toggleSightTutorial, + }); + + expect(result.current.hasCarCoverageComplianceIssues).toBe(false); + expect(toggleSightTutorial.mock.calls.length).toBe(1); + + unmount(); + }); +}); From c8e2d3a00de0c6155c2f152026dc2ac0fcb51aae Mon Sep 17 00:00:00 2001 From: Gabriel Tira Date: Thu, 6 Nov 2025 10:54:11 +0100 Subject: [PATCH 2/6] Added TS doc and improved naming --- .../src/hooks/useShowImageReference.ts | 8 +++----- .../test/hooks/useShowImageReference.test.ts | 10 +++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/inspection-capture-web/src/hooks/useShowImageReference.ts b/packages/inspection-capture-web/src/hooks/useShowImageReference.ts index 39195bfe8..97073880a 100644 --- a/packages/inspection-capture-web/src/hooks/useShowImageReference.ts +++ b/packages/inspection-capture-web/src/hooks/useShowImageReference.ts @@ -2,7 +2,8 @@ import { useMonkState, useObjectMemo } from '@monkvision/common'; import { CAR_COVERAGE_COMPLIANCE_ISSUES, Sight } from '@monkvision/types'; import { useEffect, useMemo, useState } from 'react'; -export interface UseTutorialForNonCompliantParams { +/* Parameters for the useShowImageReference hook. */ +export interface UseShowImageReferenceParams { /** The current sight. */ sight: Sight; /** Callback called if an image is not compliant. */ @@ -10,10 +11,7 @@ export interface UseTutorialForNonCompliantParams { } /* Hook to display the tutorial if the current sight is for non-compliant. */ -export function useShowImageReference({ - sight, - toggleSightTutorial, -}: UseTutorialForNonCompliantParams) { +export function useShowImageReference({ sight, toggleSightTutorial }: UseShowImageReferenceParams) { const { state } = useMonkState(); const [isMounted, setIsMounted] = useState(false); diff --git a/packages/inspection-capture-web/test/hooks/useShowImageReference.test.ts b/packages/inspection-capture-web/test/hooks/useShowImageReference.test.ts index 4cb4a89b3..4a3c73a4b 100644 --- a/packages/inspection-capture-web/test/hooks/useShowImageReference.test.ts +++ b/packages/inspection-capture-web/test/hooks/useShowImageReference.test.ts @@ -4,7 +4,7 @@ import { useMonkState } from '@monkvision/common'; import { ComplianceIssue, Sight, SightCategory, VehicleModel } from '@monkvision/types'; import { act } from '@testing-library/react'; -const waitForSyntheticMount = async (): Promise => { +const timeoutPromise = async (): Promise => { await new Promise((resolve) => { setTimeout(resolve, 1100); }); @@ -41,7 +41,7 @@ describe('useShowImageReference hook', () => { jest.clearAllMocks(); }); - it('should not fire unless component was mounted', async () => { + it('should not fire unless component was synthetically mounted', async () => { const toggleSightTutorial = jest.fn(); (useMonkState as jest.Mock).mockImplementation(() => ({ state })); const { result, unmount } = renderHook(useShowImageReference, { @@ -54,14 +54,14 @@ describe('useShowImageReference hook', () => { expect(result.current.hasCarCoverageComplianceIssues).toBe(true); expect(toggleSightTutorial.mock.calls.length).toBe(0); - await act(waitForSyntheticMount); + await act(timeoutPromise); expect(toggleSightTutorial.mock.calls.length).toBe(1); unmount(); }); - it('should trigger only if current sight has Car Coverage compliance issues', async () => { + it('should trigger only if the current sight has Car Coverage compliance issues', async () => { const toggleSightTutorial = jest.fn(); (useMonkState as jest.Mock).mockImplementation(() => ({ state })); @@ -72,7 +72,7 @@ describe('useShowImageReference hook', () => { }, }); - await act(waitForSyntheticMount); + await act(timeoutPromise); expect(result.current.hasCarCoverageComplianceIssues).toBe(true); expect(toggleSightTutorial.mock.calls.length).toBe(1); From 7adf264b9bb6edceba2cefe5b1af5a0410668e97 Mon Sep 17 00:00:00 2001 From: Gabriel Tira Date: Thu, 6 Nov 2025 11:06:36 +0100 Subject: [PATCH 3/6] Added sight as dependency for toggle --- .../inspection-capture-web/src/hooks/useShowImageReference.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/inspection-capture-web/src/hooks/useShowImageReference.ts b/packages/inspection-capture-web/src/hooks/useShowImageReference.ts index 97073880a..b78832c1a 100644 --- a/packages/inspection-capture-web/src/hooks/useShowImageReference.ts +++ b/packages/inspection-capture-web/src/hooks/useShowImageReference.ts @@ -38,7 +38,7 @@ export function useShowImageReference({ sight, toggleSightTutorial }: UseShowIma if (hasCarCoverageComplianceIssues) { toggleSightTutorial(); } - }, [isMounted, hasCarCoverageComplianceIssues]); + }, [isMounted, sight, hasCarCoverageComplianceIssues]); return useObjectMemo({ hasCarCoverageComplianceIssues, From e9ea06057822f311eb8453ec7497cb7adda4115d Mon Sep 17 00:00:00 2001 From: Gabriel Tira Date: Thu, 6 Nov 2025 19:03:05 +0100 Subject: [PATCH 4/6] Changed hook logic to onRetake --- .../src/PhotoCapture/PhotoCapture.tsx | 16 ++-- .../hooks/usePhotoCaptureSightState.ts | 15 ++++ .../src/hooks/useShowImageReference.ts | 46 ---------- .../test/PhotoCapture/PhotoCapture.test.tsx | 1 + .../hooks/usePhotoCaptureSightState.test.ts | 30 +++++++ .../test/hooks/useShowImageReference.test.ts | 90 ------------------- 6 files changed, 52 insertions(+), 146 deletions(-) delete mode 100644 packages/inspection-capture-web/src/hooks/useShowImageReference.ts delete mode 100644 packages/inspection-capture-web/test/hooks/useShowImageReference.test.ts diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx index 3a77a077c..6fd81f988 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx @@ -43,7 +43,6 @@ import { usePhotoCaptureSightTutorial, useInspectionComplete, } from './hooks'; -import { useShowImageReference } from '../hooks/useShowImageReference'; // import { SessionTimeTrackerDemo } from '../components/SessionTimeTrackerDemo'; /** @@ -195,6 +194,12 @@ export function PhotoCapture({ const onLastSightTaken = () => { setCurrentScreen(PhotoCaptureScreen.GALLERY); }; + const { currentTutorialStep, goToNextTutorialStep, closeTutorial } = usePhotoCaptureTutorial({ + enableTutorial, + enableSightGuidelines, + enableSightTutorial, + }); + const { showSightTutorial, toggleSightTutorial } = usePhotoCaptureSightTutorial(); const sightState = usePhotoCaptureSightState({ inspectionId, captureSights: sights, @@ -204,15 +209,6 @@ export function PhotoCapture({ tasksBySight, complianceOptions, setIsInitialInspectionFetched, - }); - const { currentTutorialStep, goToNextTutorialStep, closeTutorial } = usePhotoCaptureTutorial({ - enableTutorial, - enableSightGuidelines, - enableSightTutorial, - }); - const { showSightTutorial, toggleSightTutorial } = usePhotoCaptureSightTutorial(); - useShowImageReference({ - sight: sightState.selectedSight, toggleSightTutorial, }); const { showSightGuidelines, handleDisableSightGuidelines } = usePhotoCaptureSightGuidelines({ diff --git a/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureSightState.ts b/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureSightState.ts index 84eabacab..562bf24a4 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureSightState.ts +++ b/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureSightState.ts @@ -22,6 +22,7 @@ import { TaskName, ImageStatus, ProgressStatus, + CAR_COVERAGE_COMPLIANCE_ISSUES, } from '@monkvision/types'; import { sights } from '@monkvision/sights'; import { useAnalytics } from '@monkvision/analytics'; @@ -114,6 +115,8 @@ export interface PhotoCaptureSightsParams { * sight will be used. */ tasksBySight?: Record; + /** Callback called to show the tutorial when retaking a non Car Coverage compliant sight. */ + toggleSightTutorial: () => void; } function getCaptureTasks( @@ -209,6 +212,7 @@ export function usePhotoCaptureSightState({ tasksBySight, setIsInitialInspectionFetched, complianceOptions, + toggleSightTutorial, }: PhotoCaptureSightsParams): PhotoCaptureSightState { if (captureSights.length === 0) { throw new Error('Empty sight list given to the Monk PhotoCapture component.'); @@ -334,6 +338,17 @@ export function usePhotoCaptureSightState({ if (sightToRetake) { setSelectedSight(sightToRetake); } + + const hasCarCoverageComplianceIssues = state.images.some((image) => { + return ( + image.sightId === id && + image.complianceIssues && + image.complianceIssues.some((issue) => CAR_COVERAGE_COMPLIANCE_ISSUES.includes(issue)) + ); + }); + if (hasCarCoverageComplianceIssues) { + toggleSightTutorial(); + } }, [captureSights], ); diff --git a/packages/inspection-capture-web/src/hooks/useShowImageReference.ts b/packages/inspection-capture-web/src/hooks/useShowImageReference.ts deleted file mode 100644 index b78832c1a..000000000 --- a/packages/inspection-capture-web/src/hooks/useShowImageReference.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useMonkState, useObjectMemo } from '@monkvision/common'; -import { CAR_COVERAGE_COMPLIANCE_ISSUES, Sight } from '@monkvision/types'; -import { useEffect, useMemo, useState } from 'react'; - -/* Parameters for the useShowImageReference hook. */ -export interface UseShowImageReferenceParams { - /** The current sight. */ - sight: Sight; - /** Callback called if an image is not compliant. */ - toggleSightTutorial: () => void; -} - -/* Hook to display the tutorial if the current sight is for non-compliant. */ -export function useShowImageReference({ sight, toggleSightTutorial }: UseShowImageReferenceParams) { - const { state } = useMonkState(); - const [isMounted, setIsMounted] = useState(false); - - /* Allow time for the Sights Slider animation to finish */ - useEffect(() => { - setTimeout(() => setIsMounted(true), 1000); - }, []); - - const hasCarCoverageComplianceIssues = useMemo(() => { - return state.images.some((image) => { - return ( - image.sightId === sight.id && - image.complianceIssues && - image.complianceIssues.some((issue) => CAR_COVERAGE_COMPLIANCE_ISSUES.includes(issue)) - ); - }); - }, [sight, state.images]); - - useEffect(() => { - if (!isMounted) { - return; - } - - if (hasCarCoverageComplianceIssues) { - toggleSightTutorial(); - } - }, [isMounted, sight, hasCarCoverageComplianceIssues]); - - return useObjectMemo({ - hasCarCoverageComplianceIssues, - }); -} diff --git a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx index 49876a329..77525b8a0 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx +++ b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx @@ -234,6 +234,7 @@ describe('PhotoCapture component', () => { apiConfig: props.apiConfig, loading, onLastSightTaken: expect.any(Function), + toggleSightTutorial: expect.any(Function), tasksBySight: props.tasksBySight, complianceOptions: { enableCompliance: props.enableCompliance, diff --git a/packages/inspection-capture-web/test/PhotoCapture/hooks/usePhotoCaptureSightState.test.ts b/packages/inspection-capture-web/test/PhotoCapture/hooks/usePhotoCaptureSightState.test.ts index 762caf340..976b25e85 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/hooks/usePhotoCaptureSightState.test.ts +++ b/packages/inspection-capture-web/test/PhotoCapture/hooks/usePhotoCaptureSightState.test.ts @@ -48,6 +48,7 @@ function createParams(): PhotoCaptureSightsParams { useLiveCompliance: true, }, setIsInitialInspectionFetched: jest.fn(), + toggleSightTutorial: jest.fn(), }; } @@ -332,6 +333,35 @@ describe('usePhotoCaptureSightState hook', () => { unmount(); }); + it('tutorial should not be triggered when a new sight is taken', () => { + const initialProps = createParams(); + const { result, unmount } = renderHook(usePhotoCaptureSightState, { initialProps }); + + act(() => result.current.selectSight(initialProps.captureSights[0])); + expect((initialProps.toggleSightTutorial as jest.Mock).mock.calls.length).toBe(0); + + unmount(); + }); + + it('tutorial should be triggered only when retaking a non-compliant Car Coverage sight', () => { + const initialProps = createParams(); + (useMonkState as jest.Mock).mockImplementationOnce(() => ({ + state: { + images: [ + { sightId: 'test-sight-1', complianceIssues: [ComplianceIssue.NO_VEHICLE] }, // Car Coverage issue + { sightId: 'test-sight-2', complianceIssues: [ComplianceIssue.TOO_ZOOMED] }, // Non Car Coverage issue + ], + }, + })); + const { result, unmount } = renderHook(usePhotoCaptureSightState, { initialProps }); + + act(() => result.current.retakeSight('test-sight-1')); + act(() => result.current.retakeSight('test-sight-2')); + expect((initialProps.toggleSightTutorial as jest.Mock).mock.calls.length).toBe(1); + + unmount(); + }); + it('should change the sightSelected when retaking a sight', () => { const initialProps = createParams(); const { result, unmount } = renderHook(usePhotoCaptureSightState, { initialProps }); diff --git a/packages/inspection-capture-web/test/hooks/useShowImageReference.test.ts b/packages/inspection-capture-web/test/hooks/useShowImageReference.test.ts deleted file mode 100644 index 4a3c73a4b..000000000 --- a/packages/inspection-capture-web/test/hooks/useShowImageReference.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { renderHook } from '@testing-library/react-hooks'; -import { useShowImageReference } from '../../src/hooks/useShowImageReference'; -import { useMonkState } from '@monkvision/common'; -import { ComplianceIssue, Sight, SightCategory, VehicleModel } from '@monkvision/types'; -import { act } from '@testing-library/react'; - -const timeoutPromise = async (): Promise => { - await new Promise((resolve) => { - setTimeout(resolve, 1100); - }); -}; - -const nonCompliantSight: Sight = { - id: 'sight-1', - category: SightCategory.EXTERIOR, - label: 'Front View', - overlay: 'front_view_overlay.png', - tasks: [], - vehicle: VehicleModel.ALL, -}; - -const compliantSight: Sight = { - id: 'sight-2', - category: SightCategory.EXTERIOR, - label: 'Front View', - overlay: 'front_view_overlay.png', - tasks: [], - vehicle: VehicleModel.ALL, -}; - -const state = { - images: [ - { sightId: nonCompliantSight.id, id: 'id-1', complianceIssues: [ComplianceIssue.NO_VEHICLE] }, - { sightId: compliantSight.id, id: 'id-2' }, - { sightId: 'sight-3', id: 'id-3', complianceIssues: [ComplianceIssue.NO_VEHICLE] }, - ], -}; - -describe('useShowImageReference hook', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should not fire unless component was synthetically mounted', async () => { - const toggleSightTutorial = jest.fn(); - (useMonkState as jest.Mock).mockImplementation(() => ({ state })); - const { result, unmount } = renderHook(useShowImageReference, { - initialProps: { - sight: nonCompliantSight, - toggleSightTutorial, - }, - }); - - expect(result.current.hasCarCoverageComplianceIssues).toBe(true); - expect(toggleSightTutorial.mock.calls.length).toBe(0); - - await act(timeoutPromise); - - expect(toggleSightTutorial.mock.calls.length).toBe(1); - - unmount(); - }); - - it('should trigger only if the current sight has Car Coverage compliance issues', async () => { - const toggleSightTutorial = jest.fn(); - (useMonkState as jest.Mock).mockImplementation(() => ({ state })); - - const { result, unmount, rerender } = renderHook(useShowImageReference, { - initialProps: { - sight: nonCompliantSight, - toggleSightTutorial, - }, - }); - - await act(timeoutPromise); - - expect(result.current.hasCarCoverageComplianceIssues).toBe(true); - expect(toggleSightTutorial.mock.calls.length).toBe(1); - - rerender({ - sight: compliantSight, - toggleSightTutorial, - }); - - expect(result.current.hasCarCoverageComplianceIssues).toBe(false); - expect(toggleSightTutorial.mock.calls.length).toBe(1); - - unmount(); - }); -}); From 0bdb6b9c8689cb8c8e1665f3ff811a9e43e7dbdf Mon Sep 17 00:00:00 2001 From: Gabriel Tira Date: Mon, 10 Nov 2025 14:08:17 +0100 Subject: [PATCH 5/6] Fixed naming of test cases --- .../test/PhotoCapture/hooks/usePhotoCaptureSightState.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/inspection-capture-web/test/PhotoCapture/hooks/usePhotoCaptureSightState.test.ts b/packages/inspection-capture-web/test/PhotoCapture/hooks/usePhotoCaptureSightState.test.ts index 976b25e85..abdef6d32 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/hooks/usePhotoCaptureSightState.test.ts +++ b/packages/inspection-capture-web/test/PhotoCapture/hooks/usePhotoCaptureSightState.test.ts @@ -333,7 +333,7 @@ describe('usePhotoCaptureSightState hook', () => { unmount(); }); - it('tutorial should not be triggered when a new sight is taken', () => { + it('should not trigger tutorial when a new sight is taken', () => { const initialProps = createParams(); const { result, unmount } = renderHook(usePhotoCaptureSightState, { initialProps }); @@ -343,7 +343,7 @@ describe('usePhotoCaptureSightState hook', () => { unmount(); }); - it('tutorial should be triggered only when retaking a non-compliant Car Coverage sight', () => { + it('should trigger tutorial only when retaking a non-compliant Car Coverage sight', () => { const initialProps = createParams(); (useMonkState as jest.Mock).mockImplementationOnce(() => ({ state: { From 534e3fdd0f8544919eed6e23bd3f7e946e05920c Mon Sep 17 00:00:00 2001 From: Gabriel Tira Date: Mon, 10 Nov 2025 15:26:10 +0100 Subject: [PATCH 6/6] Merge commit '918df5a9fde358ba94029e23a2b38eb9304adeaa' --- .../src/PhotoCapture/PhotoCapture.tsx | 9 +- .../src/hooks/useAdaptiveCameraConfig.ts | 6 +- .../src/hooks/useBadConnectionWarning.ts | 5 +- .../src/hooks/useImagesCleanup.ts | 101 ++++++++++++++++ .../src/hooks/useUploadQueue.ts | 33 +++++- .../hooks/useAdaptiveCameraConfig.test.ts | 4 +- .../hooks/useBadConnectionWarning.test.tsx | 18 ++- .../test/hooks/useImagesCleanup.test.ts | 112 ++++++++++++++++++ .../test/hooks/useUploadQueue.test.ts | 5 +- 9 files changed, 273 insertions(+), 20 deletions(-) create mode 100644 packages/inspection-capture-web/src/hooks/useImagesCleanup.ts create mode 100644 packages/inspection-capture-web/test/hooks/useImagesCleanup.test.ts diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx index 6fd81f988..65920e9b0 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx @@ -43,7 +43,7 @@ import { usePhotoCaptureSightTutorial, useInspectionComplete, } from './hooks'; -// import { SessionTimeTrackerDemo } from '../components/SessionTimeTrackerDemo'; +import { useImagesCleanup } from '../hooks/useImagesCleanup'; /** * Props of the PhotoCapture component. @@ -219,12 +219,17 @@ export function PhotoCapture({ closeBadConnectionWarningDialog, uploadEventHandlers: badConnectionWarningUploadEventHandlers, } = useBadConnectionWarning({ maxUploadDurationWarning }); + const { cleanupEventHandlers } = useImagesCleanup({ inspectionId, apiConfig }); const uploadQueue = useUploadQueue({ inspectionId, apiConfig, additionalTasks, complianceOptions, - eventHandlers: [adaptiveUploadEventHandlers, badConnectionWarningUploadEventHandlers], + eventHandlers: [ + adaptiveUploadEventHandlers, + badConnectionWarningUploadEventHandlers, + cleanupEventHandlers, + ], }); const images = usePhotoCaptureImages(inspectionId); const handlePictureTaken = usePictureTaken({ diff --git a/packages/inspection-capture-web/src/hooks/useAdaptiveCameraConfig.ts b/packages/inspection-capture-web/src/hooks/useAdaptiveCameraConfig.ts index 95c60b2d8..6e629f52c 100644 --- a/packages/inspection-capture-web/src/hooks/useAdaptiveCameraConfig.ts +++ b/packages/inspection-capture-web/src/hooks/useAdaptiveCameraConfig.ts @@ -6,7 +6,7 @@ import { } from '@monkvision/types'; import { useCallback, useMemo, useState } from 'react'; import { useObjectMemo } from '@monkvision/common'; -import { UploadEventHandlers } from './useUploadQueue'; +import { UploadEventHandlers, UploadSuccessPayload } from './useUploadQueue'; const DEFAULT_CAMERA_CONFIG: Required = { quality: 0.6, @@ -75,8 +75,8 @@ export function useAdaptiveCameraConfig({ setIsImageUpscalingAllowed(false); }; - const onUploadSuccess = useCallback((durationMs: number) => { - if (durationMs > MAX_UPLOAD_DURATION_MS) { + const onUploadSuccess = useCallback(({ durationMs }: UploadSuccessPayload) => { + if (durationMs && durationMs > MAX_UPLOAD_DURATION_MS) { lowerMaxImageQuality(); } }, []); diff --git a/packages/inspection-capture-web/src/hooks/useBadConnectionWarning.ts b/packages/inspection-capture-web/src/hooks/useBadConnectionWarning.ts index d5714394d..438dd47e8 100644 --- a/packages/inspection-capture-web/src/hooks/useBadConnectionWarning.ts +++ b/packages/inspection-capture-web/src/hooks/useBadConnectionWarning.ts @@ -1,7 +1,7 @@ import { useCallback, useRef, useState } from 'react'; import { useObjectMemo } from '@monkvision/common'; import { PhotoCaptureAppConfig } from '@monkvision/types'; -import { UploadEventHandlers } from './useUploadQueue'; +import { UploadEventHandlers, UploadSuccessPayload } from './useUploadQueue'; /** * Parameters accepted by the useBadConnectionWarning hook. @@ -44,8 +44,9 @@ export function useBadConnectionWarning({ ); const onUploadSuccess = useCallback( - (durationMs: number) => { + ({ durationMs }: UploadSuccessPayload) => { if ( + durationMs && maxUploadDurationWarning >= 0 && durationMs > maxUploadDurationWarning && !hadDialogBeenDisplayed.current diff --git a/packages/inspection-capture-web/src/hooks/useImagesCleanup.ts b/packages/inspection-capture-web/src/hooks/useImagesCleanup.ts new file mode 100644 index 000000000..a54604b79 --- /dev/null +++ b/packages/inspection-capture-web/src/hooks/useImagesCleanup.ts @@ -0,0 +1,101 @@ +import { useMonkState, useObjectMemo } from '@monkvision/common'; +import { MonkApiConfig, useMonkApi } from '@monkvision/network'; +import { useCallback } from 'react'; +import { Image } from '@monkvision/types'; +import { UploadEventHandlers, UploadSuccessPayload } from './useUploadQueue'; + +/** + * Parameters accepted by the useImagesCleanup hook. + */ +export interface ImagesCleanupParams { + /** + * The inspection ID. + */ + inspectionId: string; + /** + * The api config used to communicate with the API. + */ + apiConfig: MonkApiConfig; +} + +/** + * Handle used to manage the images cleanup after a new one uploads. + */ +export interface ImagesCleanupHandle { + /** + * A set of event handlers listening to upload events. + */ + cleanupEventHandlers: UploadEventHandlers; +} + +function extractOtherImagesToDelete(imagesBySight: Record): Image[] { + const imagesToDelete: Image[] = []; + + Object.values(imagesBySight) + .filter((images) => images.length > 1) + .forEach((images) => { + const sortedImages = images.sort((a, b) => + b.createdAt && a.createdAt ? b.createdAt - a.createdAt : 0, + ); + imagesToDelete.push(...sortedImages.slice(1)); + }); + + return imagesToDelete; +} + +function groupImagesBySightId(images: Image[], sightIdToSkip: string): Record { + return images.reduce((acc, image) => { + if (!image.sightId || image.sightId === sightIdToSkip) { + return acc; + } + if (!acc[image.sightId]) { + acc[image.sightId] = []; + } + + acc[image.sightId].push(image); + return acc; + }, {} as Record); +} + +/** + * Custom hook used to cleanup sights' images of the inspection by deleting the old ones + * when a new image is added. + */ +export function useImagesCleanup(props: ImagesCleanupParams): ImagesCleanupHandle { + const { deleteImage } = useMonkApi(props.apiConfig); + const { state } = useMonkState(); + + const onUploadSuccess = useCallback( + ({ sightId, imageId }: UploadSuccessPayload) => { + if (!sightId) { + return; + } + + const otherImagesToDelete = extractOtherImagesToDelete( + groupImagesBySightId(state.images, sightId), + ); + + const sightImagesToDelete = state.images.filter( + (image) => + image.inspectionId === props.inspectionId && + image.sightId === sightId && + image.id !== imageId, + ); + + const imagesToDelete = [...otherImagesToDelete, ...sightImagesToDelete]; + + if (imagesToDelete.length > 0) { + imagesToDelete.forEach((image) => + deleteImage({ imageId: image.id, id: props.inspectionId }), + ); + } + }, + [state.images, props.inspectionId], + ); + + return useObjectMemo({ + cleanupEventHandlers: { + onUploadSuccess, + }, + }); +} diff --git a/packages/inspection-capture-web/src/hooks/useUploadQueue.ts b/packages/inspection-capture-web/src/hooks/useUploadQueue.ts index ff033d8a7..fdca38f8f 100644 --- a/packages/inspection-capture-web/src/hooks/useUploadQueue.ts +++ b/packages/inspection-capture-web/src/hooks/useUploadQueue.ts @@ -11,16 +11,32 @@ import { useRef } from 'react'; import { useMonitoring } from '@monkvision/monitoring'; import { CaptureMode } from '../types'; +/** + * Payload for the onUploadSuccess event handler. + */ +export interface UploadSuccessPayload { + /** + * The total elapsed time in milliseconds between the start of the upload and the end of the upload. + */ + durationMs?: number; + /** + * The sight ID associated with the uploaded picture, if applicable. + */ + sightId?: string; + /** + * The ID of the uploaded image. + */ + imageId?: string; +} + /** * Type definition for upload event handlers. */ export interface UploadEventHandlers { /** * Callback called when a picture upload successfully completes. - * - * @param durationMs The total elapsed time in milliseconds between the start of the upload and the end of the upload. */ - onUploadSuccess?: (durationMs: number) => void; + onUploadSuccess?: (payload: UploadSuccessPayload) => void; /** * Callback called when a picture upload fails because of a timeout. */ @@ -193,7 +209,7 @@ export function useUploadQueue({ } try { const startTs = Date.now(); - await addImage( + const result = await addImage( createAddImageOptions( upload, inspectionId, @@ -205,7 +221,14 @@ export function useUploadQueue({ ), ); const uploadDurationMs = Date.now() - startTs; - eventHandlers?.forEach((handlers) => handlers.onUploadSuccess?.(uploadDurationMs)); + const sightId = upload.mode === CaptureMode.SIGHT ? upload.sightId : undefined; + eventHandlers?.forEach((handlers) => + handlers.onUploadSuccess?.({ + durationMs: uploadDurationMs, + sightId, + imageId: result?.image?.id, + }), + ); } catch (err) { if ( err instanceof Error && diff --git a/packages/inspection-capture-web/test/hooks/useAdaptiveCameraConfig.test.ts b/packages/inspection-capture-web/test/hooks/useAdaptiveCameraConfig.test.ts index 957daa203..b64f5adcb 100644 --- a/packages/inspection-capture-web/test/hooks/useAdaptiveCameraConfig.test.ts +++ b/packages/inspection-capture-web/test/hooks/useAdaptiveCameraConfig.test.ts @@ -45,7 +45,7 @@ describe('useAdaptiveCameraConfigTest hook', () => { expect(result.current.adaptiveCameraConfig.resolution).toEqual( initialProps.initialCameraConfig.resolution, ); - act(() => result.current.uploadEventHandlers.onUploadSuccess?.(15001)); + act(() => result.current.uploadEventHandlers.onUploadSuccess?.({ durationMs: 15001 })); expect(result.current.adaptiveCameraConfig.resolution).toEqual(CameraResolution.QHD_2K); expect(result.current.adaptiveCameraConfig.quality).toEqual(0.6); expect(result.current.adaptiveCameraConfig.allowImageUpscaling).toEqual(false); @@ -63,7 +63,7 @@ describe('useAdaptiveCameraConfigTest hook', () => { expect(result.current.adaptiveCameraConfig.resolution).toEqual( initialProps.initialCameraConfig.resolution, ); - act(() => result.current.uploadEventHandlers.onUploadSuccess?.(200)); + act(() => result.current.uploadEventHandlers.onUploadSuccess?.({ durationMs: 200 })); expect(result.current.adaptiveCameraConfig).toEqual( expect.objectContaining(initialProps.initialCameraConfig), ); diff --git a/packages/inspection-capture-web/test/hooks/useBadConnectionWarning.test.tsx b/packages/inspection-capture-web/test/hooks/useBadConnectionWarning.test.tsx index aa0c2d121..94f1a85eb 100644 --- a/packages/inspection-capture-web/test/hooks/useBadConnectionWarning.test.tsx +++ b/packages/inspection-capture-web/test/hooks/useBadConnectionWarning.test.tsx @@ -26,7 +26,9 @@ describe('useBadConnectionWarning hook', () => { }); act(() => { - result.current.uploadEventHandlers.onUploadSuccess?.(maxUploadDurationWarning + 1); + result.current.uploadEventHandlers.onUploadSuccess?.({ + durationMs: maxUploadDurationWarning + 1, + }); }); expect(result.current.isBadConnectionWarningDialogDisplayed).toBe(true); @@ -40,7 +42,9 @@ describe('useBadConnectionWarning hook', () => { }); act(() => { - result.current.uploadEventHandlers.onUploadSuccess?.(maxUploadDurationWarning - 1); + result.current.uploadEventHandlers.onUploadSuccess?.({ + durationMs: maxUploadDurationWarning - 1, + }); }); expect(result.current.isBadConnectionWarningDialogDisplayed).toBe(false); @@ -68,7 +72,7 @@ describe('useBadConnectionWarning hook', () => { }); act(() => { - result.current.uploadEventHandlers.onUploadSuccess?.(100000); + result.current.uploadEventHandlers.onUploadSuccess?.({ durationMs: 100000 }); result.current.uploadEventHandlers.onUploadTimeout?.(); }); expect(result.current.isBadConnectionWarningDialogDisplayed).toBe(false); @@ -87,7 +91,9 @@ describe('useBadConnectionWarning hook', () => { }); expect(result.current.isBadConnectionWarningDialogDisplayed).toBe(true); act(() => { - result.current.uploadEventHandlers.onUploadSuccess?.(maxUploadDurationWarning + 1); + result.current.uploadEventHandlers.onUploadSuccess?.({ + durationMs: maxUploadDurationWarning + 1, + }); }); expect(result.current.isBadConnectionWarningDialogDisplayed).toBe(true); @@ -131,7 +137,9 @@ describe('useBadConnectionWarning hook', () => { }); expect(result.current.isBadConnectionWarningDialogDisplayed).toBe(false); act(() => { - result.current.uploadEventHandlers.onUploadSuccess?.(maxUploadDurationWarning + 1); + result.current.uploadEventHandlers.onUploadSuccess?.({ + durationMs: maxUploadDurationWarning + 1, + }); }); expect(result.current.isBadConnectionWarningDialogDisplayed).toBe(false); diff --git a/packages/inspection-capture-web/test/hooks/useImagesCleanup.test.ts b/packages/inspection-capture-web/test/hooks/useImagesCleanup.test.ts new file mode 100644 index 000000000..6ad3f9341 --- /dev/null +++ b/packages/inspection-capture-web/test/hooks/useImagesCleanup.test.ts @@ -0,0 +1,112 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useImagesCleanup } from '../../src/hooks/useImagesCleanup'; +import { act } from '@testing-library/react'; +import { useMonkApi } from '@monkvision/network'; +import { useMonkState } from '@monkvision/common'; + +const apiConfig = { + apiDomain: 'apiDomain', + authToken: 'authToken', + thumbnailDomain: 'thumbnailDomain', +}; +const inspectionId = 'inspection-123'; +const state = { + images: [ + { sightId: 'sight-1', id: 'id-1', inspectionId }, + { sightId: 'sight-1', id: 'id-2', inspectionId }, + { sightId: 'sight-1', id: 'id-3', inspectionId }, + { sightId: 'sight-2', id: 'id-4', inspectionId }, + { sightId: 'sight-2', id: 'id-5', inspectionId }, + { sightId: 'sight-3', id: 'id-6', inspectionId }, + ], +}; + +describe('useImagesCleanup hook', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should properly clean up images', () => { + const deleteImage = jest.fn(() => Promise.resolve()); + (useMonkApi as jest.Mock).mockImplementation(() => ({ deleteImage })); + (useMonkState as jest.Mock).mockImplementation(() => ({ state })); + + const { result, unmount } = renderHook(useImagesCleanup, { + initialProps: { + inspectionId, + apiConfig, + }, + }); + + const uploadedSightId = 'sight-1'; + + act(() => { + result.current.cleanupEventHandlers.onUploadSuccess?.({ sightId: uploadedSightId }); + }); + expect(deleteImage).toHaveBeenCalled(); + expect(deleteImage.mock.calls.length).toBe(4); + + unmount(); + }); + + it('should not clean up images if no upload success event is triggered', () => { + const deleteImage = jest.fn(() => Promise.resolve()); + (useMonkApi as jest.Mock).mockImplementation(() => ({ deleteImage })); + (useMonkState as jest.Mock).mockImplementation(() => ({ state })); + + const { unmount } = renderHook(useImagesCleanup, { + initialProps: { + inspectionId, + apiConfig, + }, + }); + + expect(deleteImage).not.toHaveBeenCalled(); + + unmount(); + }); + + it('should leave every sight with 1 image if sightId is not matched or undefined', () => { + const deleteImage = jest.fn(() => Promise.resolve()); + (useMonkApi as jest.Mock).mockImplementation(() => ({ deleteImage })); + (useMonkState as jest.Mock).mockImplementation(() => ({ state })); + + const { result, unmount } = renderHook(useImagesCleanup, { + initialProps: { + inspectionId, + apiConfig, + }, + }); + + const uploadedSightId = 'sight-non-matching'; + + act(() => { + result.current.cleanupEventHandlers.onUploadSuccess?.({ sightId: uploadedSightId }); + }); + + expect(deleteImage.mock.calls.length).toBe(3); + + unmount(); + }); + + it('should not clean up images if timeout occurs', () => { + const deleteImage = jest.fn(() => Promise.resolve()); + (useMonkApi as jest.Mock).mockImplementation(() => ({ deleteImage })); + (useMonkState as jest.Mock).mockImplementation(() => ({ state })); + + const { result, unmount } = renderHook(useImagesCleanup, { + initialProps: { + inspectionId, + apiConfig, + }, + }); + + act(() => { + result.current.cleanupEventHandlers.onUploadTimeout?.(); + }); + + expect(deleteImage).not.toHaveBeenCalled(); + + unmount(); + }); +}); diff --git a/packages/inspection-capture-web/test/hooks/useUploadQueue.test.ts b/packages/inspection-capture-web/test/hooks/useUploadQueue.test.ts index 68cc81c80..5813cd3d4 100644 --- a/packages/inspection-capture-web/test/hooks/useUploadQueue.test.ts +++ b/packages/inspection-capture-web/test/hooks/useUploadQueue.test.ts @@ -236,7 +236,10 @@ describe('useUploadQueue hook', () => { jest.advanceTimersByTime(durationMs); await promise; initialProps.eventHandlers?.forEach((eventHandlers) => { - expect(eventHandlers.onUploadSuccess).toHaveBeenCalledWith(durationMs); + expect(eventHandlers.onUploadSuccess).toHaveBeenCalledWith({ + durationMs, + sightId: defaultUploadOptions.sightId, + }); }); unmount();