mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	web(test): routes/Events
This commit is contained in:
		
							parent
							
								
									f87813805a
								
							
						
					
					
						commit
						fe59d90c51
					
				| @ -6,39 +6,52 @@ export function Table({ children, className = '' }) { | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export function Thead({ children, className }) { | ||||
|   return <thead className={className}>{children}</thead>; | ||||
| export function Thead({ children, className, ...attrs }) { | ||||
|   return ( | ||||
|     <thead className={className} {...attrs}> | ||||
|       {children} | ||||
|     </thead> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export function Tbody({ children, className }) { | ||||
|   return <tbody className={className}>{children}</tbody>; | ||||
| export function Tbody({ children, className, ...attrs }) { | ||||
|   return ( | ||||
|     <tbody className={className} {...attrs}> | ||||
|       {children} | ||||
|     </tbody> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export function Tfoot({ children, className = '' }) { | ||||
|   return <tfoot className={`${className}`}>{children}</tfoot>; | ||||
| export function Tfoot({ children, className = '', ...attrs }) { | ||||
|   return ( | ||||
|     <tfoot className={`${className}`} {...attrs}> | ||||
|       {children} | ||||
|     </tfoot> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export function Tr({ children, className = '' }) { | ||||
| export function Tr({ children, className = '', ...attrs }) { | ||||
|   return ( | ||||
|     <tr | ||||
|       className={`border-b border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 ${className}`} | ||||
|       {...attrs} | ||||
|     > | ||||
|       {children} | ||||
|     </tr> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export function Th({ children, className = '', colspan }) { | ||||
| export function Th({ children, className = '', colspan, ...attrs }) { | ||||
|   return ( | ||||
|     <th className={`border-b border-gray-400 p-2 px-1 lg:p-4 text-left ${className}`} colSpan={colspan}> | ||||
|     <th className={`border-b border-gray-400 p-2 px-1 lg:p-4 text-left ${className}`} colSpan={colspan} {...attrs}> | ||||
|       {children} | ||||
|     </th> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export function Td({ children, className = '', colspan }) { | ||||
| export function Td({ children, className = '', colspan, ...attrs }) { | ||||
|   return ( | ||||
|     <td className={`p-2 px-1 lg:p-4 ${className}`} colSpan={colspan}> | ||||
|     <td className={`p-2 px-1 lg:p-4 ${className}`} colSpan={colspan} {...attrs}> | ||||
|       {children} | ||||
|     </td> | ||||
|   ); | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| import { useEffect, useMemo, useState } from 'preact/hooks'; | ||||
| import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; | ||||
| 
 | ||||
| export function useResizeObserver(...refs) { | ||||
|   const [dimensions, setDimensions] = useState( | ||||
| @ -28,3 +28,32 @@ export function useResizeObserver(...refs) { | ||||
| 
 | ||||
|   return dimensions; | ||||
| } | ||||
| 
 | ||||
| export function useIntersectionObserver() { | ||||
|   const [entry, setEntry] = useState({}); | ||||
|   const [node, setNode] = useState(null); | ||||
| 
 | ||||
|   const observer = useRef(null); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (observer.current) { | ||||
|       observer.current.disconnect(); | ||||
|     } | ||||
| 
 | ||||
|     observer.current = new IntersectionObserver((entries) => { | ||||
|       window.requestAnimationFrame(() => { | ||||
|         setEntry(entries[0]); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     if (node) { | ||||
|       observer.current.observe(node); | ||||
|     } | ||||
| 
 | ||||
|     return () => { | ||||
|       observer.current.disconnect(); | ||||
|     }; | ||||
|   }, [node]); | ||||
| 
 | ||||
|   return [entry, setNode]; | ||||
| } | ||||
|  | ||||
| @ -5,11 +5,12 @@ import Link from '../components/Link'; | ||||
| import Select from '../components/Select'; | ||||
| import produce from 'immer'; | ||||
| import { route } from 'preact-router'; | ||||
| import { useIntersectionObserver } from '../hooks'; | ||||
| import { FetchStatus, useApiHost, useConfig, useEvents } from '../api'; | ||||
| import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from '../components/Table'; | ||||
| import { useCallback, useEffect, useMemo, useRef, useReducer, useState } from 'preact/hooks'; | ||||
| import { useCallback, useEffect, useMemo, useReducer, useState } from 'preact/hooks'; | ||||
| 
 | ||||
| const API_LIMIT = 25; | ||||
| const API_LIMIT = 5; | ||||
| 
 | ||||
| const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {} }); | ||||
| const reducer = (state = initialState, action) => { | ||||
| @ -43,69 +44,59 @@ const reducer = (state = initialState, action) => { | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| const defaultSearchString = `include_thumbnails=0&limit=${API_LIMIT}`; | ||||
| const defaultSearchString = (limit) => `include_thumbnails=0&limit=${limit}`; | ||||
| function removeDefaultSearchKeys(searchParams) { | ||||
|   searchParams.delete('limit'); | ||||
|   searchParams.delete('include_thumbnails'); | ||||
|   searchParams.delete('before'); | ||||
| } | ||||
| 
 | ||||
| export default function Events({ path: pathname } = {}) { | ||||
| export default function Events({ path: pathname, limit = API_LIMIT } = {}) { | ||||
|   const apiHost = useApiHost(); | ||||
|   const [{ events, reachedEnd, searchStrings }, dispatch] = useReducer(reducer, initialState); | ||||
|   const { searchParams: initialSearchParams } = new URL(window.location); | ||||
|   const [searchString, setSearchString] = useState(`${defaultSearchString}&${initialSearchParams.toString()}`); | ||||
|   const [searchString, setSearchString] = useState(`${defaultSearchString(limit)}&${initialSearchParams.toString()}`); | ||||
|   const { data, status } = useEvents(searchString); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (data && !(searchString in searchStrings)) { | ||||
|       dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } }); | ||||
|     } | ||||
|     if (Array.isArray(data) && data.length < API_LIMIT) { | ||||
| 
 | ||||
|     if (data && Array.isArray(data) && data.length < limit) { | ||||
|       dispatch({ type: 'REACHED_END', meta: { searchString } }); | ||||
|     } | ||||
|   }, [data, searchString, searchStrings]); | ||||
|   }, [data, limit, searchString, searchStrings]); | ||||
| 
 | ||||
|   const observer = useRef( | ||||
|     new IntersectionObserver((entries, observer) => { | ||||
|       window.requestAnimationFrame(() => { | ||||
|         if (entries.length === 0) { | ||||
|           return; | ||||
|         } | ||||
|         // under certain edge cases, a ref may be applied / in memory twice | ||||
|         // avoid fetching twice by grabbing the last observed entry only | ||||
|         const entry = entries[entries.length - 1]; | ||||
|         if (entry.isIntersecting) { | ||||
|           const { startTime } = entry.target.dataset; | ||||
|           const { searchParams } = new URL(window.location); | ||||
|           searchParams.set('before', parseFloat(startTime) - 0.0001); | ||||
|   const [entry, setIntersectNode] = useIntersectionObserver(); | ||||
| 
 | ||||
|           setSearchString(`${defaultSearchString}&${searchParams.toString()}`); | ||||
|         } | ||||
|       }); | ||||
|     }) | ||||
|   ); | ||||
|   useEffect(() => { | ||||
|     if (entry && entry.isIntersecting) { | ||||
|       const { startTime } = entry.target.dataset; | ||||
|       const { searchParams } = new URL(window.location); | ||||
|       searchParams.set('before', parseFloat(startTime) - 0.0001); | ||||
| 
 | ||||
|       setSearchString(`${defaultSearchString(limit)}&${searchParams.toString()}`); | ||||
|     } | ||||
|   }, [entry, limit]); | ||||
| 
 | ||||
|   const lastCellRef = useCallback( | ||||
|     (node) => { | ||||
|       if (node !== null) { | ||||
|         observer.current.disconnect(); | ||||
|         if (!reachedEnd) { | ||||
|           observer.current.observe(node); | ||||
|         } | ||||
|       if (node !== null && !reachedEnd) { | ||||
|         setIntersectNode(node); | ||||
|       } | ||||
|     }, | ||||
|     [observer, reachedEnd] | ||||
|     [setIntersectNode, reachedEnd] | ||||
|   ); | ||||
| 
 | ||||
|   const handleFilter = useCallback( | ||||
|     (searchParams) => { | ||||
|       dispatch({ type: 'RESET' }); | ||||
|       removeDefaultSearchKeys(searchParams); | ||||
|       setSearchString(`${defaultSearchString}&${searchParams.toString()}`); | ||||
|       setSearchString(`${defaultSearchString(limit)}&${searchParams.toString()}`); | ||||
|       route(`${pathname}?${searchParams.toString()}`); | ||||
|     }, | ||||
|     [pathname, setSearchString] | ||||
|     [limit, pathname, setSearchString] | ||||
|   ); | ||||
| 
 | ||||
|   const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]); | ||||
| @ -140,7 +131,7 @@ export default function Events({ path: pathname } = {}) { | ||||
|                 const end = new Date(parseInt(endTime * 1000, 10)); | ||||
|                 const ref = i === events.length - 1 ? lastCellRef : undefined; | ||||
|                 return ( | ||||
|                   <Tr key={id}> | ||||
|                   <Tr data-testid={`event-${id}`} key={id}> | ||||
|                     <Td className="w-40"> | ||||
|                       <a href={`/events/${id}`} ref={ref} data-start-time={startTime} data-reached-end={reachedEnd}> | ||||
|                         <img | ||||
|  | ||||
							
								
								
									
										83
									
								
								web/src/routes/__tests__/Events.test.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								web/src/routes/__tests__/Events.test.jsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,83 @@ | ||||
| import { h } from 'preact'; | ||||
| import * as Api from '../../api'; | ||||
| import * as Hooks from '../../hooks'; | ||||
| import Events from '../Events'; | ||||
| import { render, screen } from '@testing-library/preact'; | ||||
| 
 | ||||
| describe('Events Route', () => { | ||||
|   let useEventsMock, useIntersectionMock; | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     useEventsMock = jest.spyOn(Api, 'useEvents').mockImplementation(() => ({ | ||||
|       data: null, | ||||
|       status: 'loading', | ||||
|     })); | ||||
|     jest.spyOn(Api, 'useConfig').mockImplementation(() => ({ | ||||
|       data: { | ||||
|         cameras: { | ||||
|           front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] }, zones: [] }, | ||||
|           side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] }, zones: [] }, | ||||
|         }, | ||||
|       }, | ||||
|     })); | ||||
|     jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://localhost:5000'); | ||||
|     useIntersectionMock = jest.spyOn(Hooks, 'useIntersectionObserver').mockImplementation(() => [null, jest.fn()]); | ||||
|   }); | ||||
| 
 | ||||
|   test('shows an ActivityIndicator if not yet loaded', async () => { | ||||
|     render(<Events limit={5} path="/events" />); | ||||
|     expect(screen.queryByLabelText('Loading…')).toBeInTheDocument(); | ||||
|   }); | ||||
| 
 | ||||
|   test('does not show ActivityIndicator after loaded', async () => { | ||||
|     useEventsMock.mockReturnValue({ data: mockEvents, status: 'loaded' }); | ||||
|     render(<Events limit={5} path="/events" />); | ||||
|     expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument(); | ||||
|   }); | ||||
| 
 | ||||
|   test('loads more when the intersectionObserver fires', async () => { | ||||
|     const setIntersectionNode = jest.fn(); | ||||
|     useIntersectionMock.mockReturnValue([null, setIntersectionNode]); | ||||
|     useEventsMock.mockImplementation((searchString) => { | ||||
|       if (searchString.includes('before=')) { | ||||
|         const params = new URLSearchParams(searchString); | ||||
|         const before = parseFloat(params.get('before')); | ||||
|         const index = mockEvents.findIndex((el) => el.start_time === before + 0.0001); | ||||
|         return { data: mockEvents.slice(index, index + 5), status: 'loaded' }; | ||||
|       } | ||||
| 
 | ||||
|       return { data: mockEvents.slice(0, 5), status: 'loaded' }; | ||||
|     }); | ||||
| 
 | ||||
|     const { rerender } = render(<Events limit={5} path="/events" />); | ||||
|     expect(setIntersectionNode).toHaveBeenCalled(); | ||||
|     expect(useEventsMock).toHaveBeenCalledWith('include_thumbnails=0&limit=5&'); | ||||
|     expect(screen.queryAllByTestId(/event-\d+/)).toHaveLength(5); | ||||
| 
 | ||||
|     useIntersectionMock.mockReturnValue([ | ||||
|       { | ||||
|         isIntersecting: true, | ||||
|         target: { dataset: { startTime: mockEvents[4].start_time } }, | ||||
|       }, | ||||
|       setIntersectionNode, | ||||
|     ]); | ||||
|     rerender(<Events limit={5} path="/events" />); | ||||
|     expect(useEventsMock).toHaveBeenCalledWith( | ||||
|       `include_thumbnails=0&limit=5&before=${mockEvents[4].start_time - 0.0001}` | ||||
|     ); | ||||
|     expect(screen.queryAllByTestId(/event-\d+/)).toHaveLength(10); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| const mockEvents = new Array(12).fill(null).map((v, i) => ({ | ||||
|   end_time: 1613257337 + i, | ||||
|   false_positive: false, | ||||
|   has_clip: true, | ||||
|   has_snapshot: true, | ||||
|   id: i, | ||||
|   label: 'person', | ||||
|   start_time: 1613257326 + i, | ||||
|   top_score: Math.random(), | ||||
|   zones: ['front_patio'], | ||||
|   thumbnail: '/9j/4aa...', | ||||
| })); | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user