소스 검색

web(test): routes/Events

Paul Armstrong 4 년 전
부모
커밋
fe59d90c51
4개의 변경된 파일162개의 추가작업 그리고 46개의 파일을 삭제
  1. 24 11
      web/src/components/Table.jsx
  2. 30 1
      web/src/hooks/index.jsx
  3. 25 34
      web/src/routes/Events.jsx
  4. 83 0
      web/src/routes/__tests__/Events.test.jsx

+ 24 - 11
web/src/components/Table.jsx

@@ -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>
   );

+ 30 - 1
web/src/hooks/index.jsx

@@ -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];
+}

+ 25 - 34
web/src/routes/Events.jsx

@@ -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 - 0
web/src/routes/__tests__/Events.test.jsx

@@ -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...',
+}));