From b6ba6459fb5aacca9ea72d9ea7284d70453cddce Mon Sep 17 00:00:00 2001 From: Paul Armstrong Date: Wed, 17 Feb 2021 20:53:57 -0800 Subject: [PATCH] feat(web): detect, clips, snapshots toggles --- web/config/setupTests.js | 6 - web/public/index.html | 1 + web/src/api/__tests__/index.test.jsx | 81 ++++++------ web/src/api/__tests__/mqtt.test.jsx | 34 +++++- web/src/api/index.jsx | 11 +- web/src/api/mqtt.jsx | 50 +++++++- web/src/components/Button.jsx | 48 ++++++-- web/src/components/Card.jsx | 11 +- web/src/components/Tooltip.jsx | 61 ++++++++++ .../components/__tests__/Toolltip.test.jsx | 115 ++++++++++++++++++ web/src/icons/Clip.jsx | 13 ++ web/src/icons/Motion.jsx | 12 ++ web/src/icons/Snapshot.jsx | 14 +++ web/src/routes/Cameras.jsx | 40 +++++- web/src/routes/Debug.jsx | 2 +- web/src/routes/StyleGuide.jsx | 7 ++ web/src/routes/__tests__/Cameras.test.jsx | 37 +++++- web/src/routes/__tests__/Debug.test.jsx | 49 ++++---- 18 files changed, 500 insertions(+), 92 deletions(-) create mode 100644 web/src/components/Tooltip.jsx create mode 100644 web/src/components/__tests__/Toolltip.test.jsx create mode 100644 web/src/icons/Clip.jsx create mode 100644 web/src/icons/Motion.jsx create mode 100644 web/src/icons/Snapshot.jsx diff --git a/web/config/setupTests.js b/web/config/setupTests.js index af63ce51f..42669935a 100644 --- a/web/config/setupTests.js +++ b/web/config/setupTests.js @@ -15,10 +15,4 @@ Object.defineProperty(window, 'matchMedia', { window.fetch = () => Promise.resolve(); -beforeEach(() => { - jest.spyOn(window, 'fetch').mockImplementation(async (url, opts = {}) => { - throw new Error(`Unexpected fetch to ${url}, ${JSON.stringify(opts)}`); - }); -}); - jest.mock('../src/env'); diff --git a/web/public/index.html b/web/public/index.html index 008438567..1ee216246 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -16,6 +16,7 @@
+
diff --git a/web/src/api/__tests__/index.test.jsx b/web/src/api/__tests__/index.test.jsx index c8b347722..4b87f9457 100644 --- a/web/src/api/__tests__/index.test.jsx +++ b/web/src/api/__tests__/index.test.jsx @@ -1,8 +1,13 @@ import { h } from 'preact'; +import * as Mqtt from '../mqtt'; import { ApiProvider, useFetch, useApiHost } from '..'; import { render, screen } from '@testing-library/preact'; describe('useApiHost', () => { + beforeEach(() => { + jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children); + }); + test('is set from the baseUrl', async () => { function Test() { const apiHost = useApiHost(); @@ -17,26 +22,34 @@ describe('useApiHost', () => { }); }); -describe('useFetch', () => { - function Test() { - const { data, status } = useFetch('/api/tacos'); - return ( -
- {data ? data.returnData : ''} - {status} -
- ); - } - test('loads data', async () => { - const fetchSpy = jest.spyOn(window, 'fetch').mockImplementation( - (url) => - new Promise((resolve) => { - setTimeout(() => { - resolve({ ok: true, json: () => Promise.resolve({ returnData: 'yep' }) }); - }, 1); - }) - ); +function Test() { + const { data, status } = useFetch('/api/tacos'); + return ( +
+ {data ? data.returnData : ''} + {status} +
+ ); +} +describe('useFetch', () => { + let fetchSpy; + + beforeEach(() => { + jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children); + fetchSpy = jest.spyOn(window, 'fetch').mockImplementation(async (url, options) => { + if (url.endsWith('/api/config')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + return new Promise((resolve) => { + setTimeout(() => { + resolve({ ok: true, json: () => Promise.resolve({ returnData: 'yep' }) }); + }, 1); + }); + }); + }); + + test('loads data', async () => { render( @@ -55,14 +68,16 @@ describe('useFetch', () => { }); test('sets error if response is not okay', async () => { - jest.spyOn(window, 'fetch').mockImplementation( - (url) => - new Promise((resolve) => { - setTimeout(() => { - resolve({ ok: false }); - }, 1); - }) - ); + jest.spyOn(window, 'fetch').mockImplementation((url) => { + if (url.includes('/config')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + return new Promise((resolve) => { + setTimeout(() => { + resolve({ ok: false }); + }, 1); + }); + }); render( @@ -76,15 +91,6 @@ describe('useFetch', () => { }); test('does not re-fetch if the query has already been made', async () => { - const fetchSpy = jest.spyOn(window, 'fetch').mockImplementation( - (url) => - new Promise((resolve) => { - setTimeout(() => { - resolve({ ok: true, json: () => Promise.resolve({ returnData: 'yep' }) }); - }, 1); - }) - ); - const { rerender } = render( @@ -109,6 +115,7 @@ describe('useFetch', () => { jest.runAllTimers(); - expect(fetchSpy).toHaveBeenCalledTimes(1); + // once for /api/config, once for /api/tacos + expect(fetchSpy).toHaveBeenCalledTimes(2); }); }); diff --git a/web/src/api/__tests__/mqtt.test.jsx b/web/src/api/__tests__/mqtt.test.jsx index 23328ca24..8e1705b7c 100644 --- a/web/src/api/__tests__/mqtt.test.jsx +++ b/web/src/api/__tests__/mqtt.test.jsx @@ -45,7 +45,7 @@ describe('MqttProvider', () => { test('connects to the mqtt server', async () => { render( - + ); @@ -69,7 +69,7 @@ describe('MqttProvider', () => { } const { rerender } = render( - + ); @@ -78,7 +78,7 @@ describe('MqttProvider', () => { data: JSON.stringify({ topic: 'tacos', payload: JSON.stringify({ yes: true }), retain: false }), }); rerender( - + ); @@ -96,7 +96,7 @@ describe('MqttProvider', () => { } render( - + ); @@ -106,4 +106,30 @@ describe('MqttProvider', () => { JSON.stringify({ topic: 'tacos', payload: JSON.stringify({ yes: true }) }) ); }); + + test('prefills the clips/detect/snapshots state from config', async () => { + jest.spyOn(Date, 'now').mockReturnValue(123456); + const config = { + cameras: { + front: { name: 'front', detect: { enabled: true }, clips: { enabled: false }, snapshots: { enabled: true } }, + side: { name: 'side', detect: { enabled: false }, clips: { enabled: false }, snapshots: { enabled: false } }, + }, + }; + render( + + + + ); + await screen.findByTestId('data'); + expect(screen.getByTestId('front/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON"}'); + expect(screen.getByTestId('front/clips/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}'); + expect(screen.getByTestId('front/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON"}'); + expect(screen.getByTestId('side/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}'); + expect(screen.getByTestId('side/clips/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}'); + expect(screen.getByTestId('side/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}'); + }); }); + +const mockConfig = { + cameras: {}, +}; diff --git a/web/src/api/index.jsx b/web/src/api/index.jsx index 6084aca11..2e84a4159 100644 --- a/web/src/api/index.jsx +++ b/web/src/api/index.jsx @@ -40,14 +40,19 @@ function reducer(state, { type, payload, meta }) { } } -export const ApiProvider = ({ children }) => { +export function ApiProvider({ children }) { const [state, dispatch] = useReducer(reducer, initialState); return ( - {children} + {children} ); -}; +} + +function MqttWithConfig({ children }) { + const { data, status } = useConfig(); + return status === FetchStatus.LOADED ? {children} : children; +} function shouldFetch(state, url, fetchId = null) { if ((fetchId && url in state.queries && state.queries[url].fetchId !== fetchId) || !(url in state.queries)) { diff --git a/web/src/api/mqtt.jsx b/web/src/api/mqtt.jsx index c582d201a..2d45593c7 100644 --- a/web/src/api/mqtt.jsx +++ b/web/src/api/mqtt.jsx @@ -31,6 +31,7 @@ function reducer(state, { topic, payload, retain }) { } export function MqttProvider({ + config, children, createWebsocket = defaultCreateWebsocket, mqttUrl = `${baseUrl.replace(/^https?:/, 'ws:')}/ws`, @@ -38,6 +39,15 @@ export function MqttProvider({ const [state, dispatch] = useReducer(reducer, initialState); const wsRef = useRef(); + useEffect(() => { + Object.keys(config.cameras).forEach((camera) => { + const { name, clips, detect, snapshots } = config.cameras[camera]; + dispatch({ topic: `${name}/clips/state`, payload: clips.enabled ? 'ON' : 'OFF' }); + dispatch({ topic: `${name}/detect/state`, payload: detect.enabled ? 'ON' : 'OFF' }); + dispatch({ topic: `${name}/snapshots/state`, payload: snapshots.enabled ? 'ON' : 'OFF' }); + }); + }, [config]); + useEffect( () => { const ws = createWebsocket(mqttUrl); @@ -62,17 +72,49 @@ export function MqttProvider({ return {children}; } -export function useMqtt(topic) { +export function useMqtt(watchTopic, publishTopic) { const { state, ws } = useContext(Mqtt); - const value = state[topic] || { payload: null }; + const value = state[watchTopic] || { payload: null }; const send = useCallback( (payload) => { - ws.send(JSON.stringify({ topic, payload: typeof payload !== 'string' ? JSON.stringify(payload) : payload })); + ws.send( + JSON.stringify({ + topic: publishTopic || watchTopic, + payload: typeof payload !== 'string' ? JSON.stringify(payload) : payload, + }) + ); }, - [ws, topic] + [ws, watchTopic, publishTopic] ); return { value, send, connected: state.__connected }; } + +export function useDetectState(camera) { + const { + value: { payload }, + send, + connected, + } = useMqtt(`${camera}/detect/state`, `${camera}/detect/set`); + return { payload, send, connected }; +} + +export function useClipsState(camera) { + const { + value: { payload }, + send, + connected, + } = useMqtt(`${camera}/clips/state`, `${camera}/clips/set`); + return { payload, send, connected }; +} + +export function useSnapshotsState(camera) { + const { + value: { payload }, + send, + connected, + } = useMqtt(`${camera}/snapshots/state`, `${camera}/snapshots/set`); + return { payload, send, connected }; +} diff --git a/web/src/components/Button.jsx b/web/src/components/Button.jsx index f773cb6b3..673feaf1f 100644 --- a/web/src/components/Button.jsx +++ b/web/src/components/Button.jsx @@ -1,4 +1,6 @@ -import { h } from 'preact'; +import { h, Fragment } from 'preact'; +import Tooltip from './Tooltip'; +import { useCallback, useRef, useState } from 'preact/hooks'; const ButtonColors = { blue: { @@ -22,6 +24,13 @@ const ButtonColors = { text: 'text-green-500 hover:bg-green-500 hover:bg-opacity-20 focus:bg-green-500 focus:bg-opacity-40 active:bg-green-500 active:bg-opacity-40', }, + gray: { + contained: 'bg-gray-500 focus:bg-gray-400 active:bg-gray-600 ring-gray-300', + outlined: + 'text-gray-500 border-2 border-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40', + text: + 'text-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40', + }, disabled: { contained: 'bg-gray-400', outlined: @@ -52,6 +61,9 @@ export default function Button({ type = 'contained', ...attrs }) { + const [hovered, setHovered] = useState(false); + const ref = useRef(); + let classes = `whitespace-nowrap flex items-center space-x-1 ${className} ${ButtonTypes[type]} ${ ButtonColors[disabled ? 'disabled' : color][type] } font-sans inline-flex font-bold uppercase text-xs px-2 py-2 rounded outline-none focus:outline-none ring-opacity-50 transition-shadow transition-colors ${ @@ -62,18 +74,32 @@ export default function Button({ classes = classes.replace(/(?:focus|active|hover):[^ ]+/g, ''); } + const handleMousenter = useCallback((event) => { + setHovered(true); + }, []); + + const handleMouseleave = useCallback((event) => { + setHovered(false); + }, []); + const Element = href ? 'a' : 'div'; return ( - - {children} - + + + {children} + + {hovered && attrs['aria-label'] ? : null} + ); } diff --git a/web/src/components/Card.jsx b/web/src/components/Card.jsx index 26577db9d..7b366d98b 100644 --- a/web/src/components/Card.jsx +++ b/web/src/components/Card.jsx @@ -9,6 +9,7 @@ export default function Box({ elevated = true, header, href, + icons = [], media = null, ...props }) { @@ -26,8 +27,8 @@ export default function Box({
{header ? {header} : null}
) : null} - {buttons.length || content ? ( -
+ {buttons.length || content || icons.length ? ( +
{content || null} {buttons.length ? (
@@ -36,6 +37,12 @@ export default function Box({ {name} ))} +
+ {icons.map(({ name, icon: Icon, ...props }) => ( + + ))}
) : null}
diff --git a/web/src/components/Tooltip.jsx b/web/src/components/Tooltip.jsx new file mode 100644 index 000000000..b0faadaef --- /dev/null +++ b/web/src/components/Tooltip.jsx @@ -0,0 +1,61 @@ +import { h } from 'preact'; +import { createPortal } from 'preact/compat'; +import { useEffect, useRef, useState } from 'preact/hooks'; + +const TIP_SPACE = 20; + +export default function Tooltip({ relativeTo, text }) { + const [position, setPosition] = useState({ top: -Infinity, left: -Infinity }); + const portalRoot = document.getElementById('tooltips'); + const ref = useRef(); + + useEffect(() => { + if (ref && ref.current && relativeTo && relativeTo.current) { + const windowWidth = window.innerWidth; + const { + x: relativeToX, + y: relativeToY, + width: relativeToWidth, + height: relativeToHeight, + } = relativeTo.current.getBoundingClientRect(); + const { width: tipWidth, height: tipHeight } = ref.current.getBoundingClientRect(); + + const left = relativeToX + Math.round(relativeToWidth / 2) + window.scrollX; + const top = relativeToY + Math.round(relativeToHeight / 2) + window.scrollY; + + let newTop = top - TIP_SPACE - tipHeight; + let newLeft = left - Math.round(tipWidth / 2); + // too far right + if (newLeft + tipWidth + TIP_SPACE > windowWidth - window.scrollX) { + newLeft = left - tipWidth - TIP_SPACE; + newTop = top - Math.round(tipHeight / 2); + } + // too far left + else if (newLeft < TIP_SPACE + window.scrollX) { + newLeft = left + TIP_SPACE; + newTop = top - Math.round(tipHeight / 2); + } + // too close to top + else if (newTop <= TIP_SPACE + window.scrollY) { + newTop = top + tipHeight + TIP_SPACE; + } + + setPosition({ left: newLeft, top: newTop }); + } + }, [relativeTo, ref]); + + const tooltip = ( +
= 0 ? 'opacity-100' : '' + }`} + ref={ref} + style={position.top >= 0 ? position : null} + > + {text} +
+ ); + + return portalRoot ? createPortal(tooltip, portalRoot) : tooltip; +} diff --git a/web/src/components/__tests__/Toolltip.test.jsx b/web/src/components/__tests__/Toolltip.test.jsx new file mode 100644 index 000000000..d2d8e0c63 --- /dev/null +++ b/web/src/components/__tests__/Toolltip.test.jsx @@ -0,0 +1,115 @@ +import { h, createRef } from 'preact'; +import Tooltip from '../Tooltip'; +import { render, screen } from '@testing-library/preact'; + +describe('Tooltip', () => { + test('renders in a relative position', async () => { + jest + .spyOn(window.HTMLElement.prototype, 'getBoundingClientRect') + // relativeTo + .mockReturnValueOnce({ + x: 100, + y: 100, + width: 50, + height: 10, + }) + // tooltip + .mockReturnValueOnce({ width: 40, height: 15 }); + + const ref = createRef(); + render( +
+
+ +
+ ); + + const tooltip = await screen.findByRole('tooltip'); + const style = window.getComputedStyle(tooltip); + expect(style.left).toEqual('105px'); + expect(style.top).toEqual('70px'); + }); + + test('if too far right, renders to the left', async () => { + window.innerWidth = 1024; + jest + .spyOn(window.HTMLElement.prototype, 'getBoundingClientRect') + // relativeTo + .mockReturnValueOnce({ + x: 1000, + y: 100, + width: 24, + height: 10, + }) + // tooltip + .mockReturnValueOnce({ width: 50, height: 15 }); + + const ref = createRef(); + render( +
+
+ +
+ ); + + const tooltip = await screen.findByRole('tooltip'); + const style = window.getComputedStyle(tooltip); + expect(style.left).toEqual('942px'); + expect(style.top).toEqual('97px'); + }); + + test('if too far left, renders to the right', async () => { + jest + .spyOn(window.HTMLElement.prototype, 'getBoundingClientRect') + // relativeTo + .mockReturnValueOnce({ + x: 0, + y: 100, + width: 24, + height: 10, + }) + // tooltip + .mockReturnValueOnce({ width: 50, height: 15 }); + + const ref = createRef(); + render( +
+
+ +
+ ); + + const tooltip = await screen.findByRole('tooltip'); + const style = window.getComputedStyle(tooltip); + expect(style.left).toEqual('32px'); + expect(style.top).toEqual('97px'); + }); + + test('if too close to top, renders to the bottom', async () => { + window.scrollY = 90; + jest + .spyOn(window.HTMLElement.prototype, 'getBoundingClientRect') + // relativeTo + .mockReturnValueOnce({ + x: 100, + y: 100, + width: 24, + height: 10, + }) + // tooltip + .mockReturnValueOnce({ width: 50, height: 15 }); + + const ref = createRef(); + render( +
+
+ +
+ ); + + const tooltip = await screen.findByRole('tooltip'); + const style = window.getComputedStyle(tooltip); + expect(style.left).toEqual('87px'); + expect(style.top).toEqual('160px'); + }); +}); diff --git a/web/src/icons/Clip.jsx b/web/src/icons/Clip.jsx new file mode 100644 index 000000000..4635f3ccc --- /dev/null +++ b/web/src/icons/Clip.jsx @@ -0,0 +1,13 @@ +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +export function Clip({ className = '' }) { + return ( + + + + + ); +} + +export default memo(Clip); diff --git a/web/src/icons/Motion.jsx b/web/src/icons/Motion.jsx new file mode 100644 index 000000000..18320d465 --- /dev/null +++ b/web/src/icons/Motion.jsx @@ -0,0 +1,12 @@ +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +export function Motion({ className = '' }) { + return ( + + + + ); +} + +export default memo(Motion); diff --git a/web/src/icons/Snapshot.jsx b/web/src/icons/Snapshot.jsx new file mode 100644 index 000000000..9990571bc --- /dev/null +++ b/web/src/icons/Snapshot.jsx @@ -0,0 +1,14 @@ +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +export function Snapshot({ className = '' }) { + return ( + + + + + + ); +} + +export default memo(Snapshot); diff --git a/web/src/routes/Cameras.jsx b/web/src/routes/Cameras.jsx index 6dc0a00cc..60b9de159 100644 --- a/web/src/routes/Cameras.jsx +++ b/web/src/routes/Cameras.jsx @@ -2,6 +2,10 @@ import { h } from 'preact'; import ActivityIndicator from '../components/ActivityIndicator'; import Card from '../components/Card'; import CameraImage from '../components/CameraImage'; +import ClipIcon from '../icons/Clip'; +import MotionIcon from '../icons/Motion'; +import SnapshotIcon from '../icons/Snapshot'; +import { useDetectState, useClipsState, useSnapshotsState } from '../api/mqtt'; import { useConfig, FetchStatus } from '../api'; import { useMemo } from 'preact/hooks'; @@ -20,8 +24,42 @@ export default function Cameras() { } function Camera({ name }) { + const { payload: detectValue, send: sendDetect } = useDetectState(name); + const { payload: clipValue, send: sendClips } = useClipsState(name); + const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name); const href = `/cameras/${name}`; const buttons = useMemo(() => [{ name: 'Events', href: `/events?camera=${name}` }], [name]); + const icons = useMemo( + () => [ + { + name: `Toggle detect ${detectValue === 'ON' ? 'off' : 'on'}`, + icon: MotionIcon, + color: detectValue === 'ON' ? 'blue' : 'gray', + onClick: () => { + sendDetect(detectValue === 'ON' ? 'OFF' : 'ON'); + }, + }, + { + name: `Toggle clips ${clipValue === 'ON' ? 'off' : 'on'}`, + icon: ClipIcon, + color: clipValue === 'ON' ? 'blue' : 'gray', + onClick: () => { + sendClips(clipValue === 'ON' ? 'OFF' : 'ON'); + }, + }, + { + name: `Toggle snapshots ${snapshotValue === 'ON' ? 'off' : 'on'}`, + icon: SnapshotIcon, + color: snapshotValue === 'ON' ? 'blue' : 'gray', + onClick: () => { + sendSnapshots(snapshotValue === 'ON' ? 'OFF' : 'ON'); + }, + }, + ], + [detectValue, sendDetect, clipValue, sendClips, snapshotValue, sendSnapshots] + ); - return } />; + return ( + } /> + ); } diff --git a/web/src/routes/Debug.jsx b/web/src/routes/Debug.jsx index f77ddf56d..6e04509bd 100644 --- a/web/src/routes/Debug.jsx +++ b/web/src/routes/Debug.jsx @@ -14,7 +14,7 @@ export default function Debug() { const { data: config } = useConfig(); const { - value: { stats }, + value: { payload: stats }, } = useMqtt('stats'); const { data: initialStats } = useStats(); diff --git a/web/src/routes/StyleGuide.jsx b/web/src/routes/StyleGuide.jsx index fce992539..b0759fd63 100644 --- a/web/src/routes/StyleGuide.jsx +++ b/web/src/routes/StyleGuide.jsx @@ -25,6 +25,7 @@ export default function StyleGuide() { +
@@ -35,6 +36,9 @@ export default function StyleGuide() { + @@ -47,6 +51,9 @@ export default function StyleGuide() { + diff --git a/web/src/routes/__tests__/Cameras.test.jsx b/web/src/routes/__tests__/Cameras.test.jsx index 770ecd387..6fee585f4 100644 --- a/web/src/routes/__tests__/Cameras.test.jsx +++ b/web/src/routes/__tests__/Cameras.test.jsx @@ -1,8 +1,9 @@ import { h } from 'preact'; import * as Api from '../../api'; -import Cameras from '../Cameras'; import * as CameraImage from '../../components/CameraImage'; -import { render, screen } from '@testing-library/preact'; +import * as Mqtt from '../../api/mqtt'; +import Cameras from '../Cameras'; +import { fireEvent, render, screen } from '@testing-library/preact'; describe('Cameras Route', () => { let useConfigMock; @@ -19,6 +20,7 @@ describe('Cameras Route', () => { })); jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://base-url.local:5000'); jest.spyOn(CameraImage, 'default').mockImplementation(() =>
); + jest.spyOn(Mqtt, 'useMqtt').mockImplementation(() => ({ value: { payload: 'OFF' }, send: jest.fn() })); }); test('shows an ActivityIndicator if not yet loaded', async () => { @@ -38,4 +40,35 @@ describe('Cameras Route', () => { expect(screen.queryByText('side')).toBeInTheDocument(); expect(screen.queryByText('side').closest('a')).toHaveAttribute('href', '/cameras/side'); }); + + test('buttons toggle detect, clips, and snapshots', async () => { + const sendDetect = jest.fn(); + const sendClips = jest.fn(); + const sendSnapshots = jest.fn(); + jest.spyOn(Mqtt, 'useDetectState').mockImplementation(() => { + return { payload: 'ON', send: sendDetect }; + }); + jest.spyOn(Mqtt, 'useClipsState').mockImplementation(() => { + return { payload: 'OFF', send: sendClips }; + }); + jest.spyOn(Mqtt, 'useSnapshotsState').mockImplementation(() => { + return { payload: 'ON', send: sendSnapshots }; + }); + + render(); + + fireEvent.click(screen.getAllByLabelText('Toggle detect off')[0]); + expect(sendDetect).toHaveBeenCalledWith('OFF'); + expect(sendDetect).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getAllByLabelText('Toggle snapshots off')[0]); + expect(sendSnapshots).toHaveBeenCalledWith('OFF'); + + fireEvent.click(screen.getAllByLabelText('Toggle clips on')[0]); + expect(sendClips).toHaveBeenCalledWith('ON'); + + expect(sendDetect).toHaveBeenCalledTimes(1); + expect(sendSnapshots).toHaveBeenCalledTimes(1); + expect(sendClips).toHaveBeenCalledTimes(1); + }); }); diff --git a/web/src/routes/__tests__/Debug.test.jsx b/web/src/routes/__tests__/Debug.test.jsx index 4a14835ef..e28f033c1 100644 --- a/web/src/routes/__tests__/Debug.test.jsx +++ b/web/src/routes/__tests__/Debug.test.jsx @@ -1,10 +1,11 @@ import { h } from 'preact'; import * as Api from '../../api'; +import * as Mqtt from '../../api/mqtt'; import Debug from '../Debug'; import { render, screen } from '@testing-library/preact'; describe('Debug Route', () => { - let useStatsMock; + let useStatsMock, useMqttMock; beforeEach(() => { jest.spyOn(Api, 'useConfig').mockImplementation(() => ({ @@ -16,10 +17,14 @@ describe('Debug Route', () => { front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] } }, side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] } }, }, + mqtt: { + stats_interva: 60, + }, }, status: 'loaded', })); - useStatsMock = jest.spyOn(Api, 'useStats').mockImplementation(() => statsMock); + useStatsMock = jest.spyOn(Api, 'useStats').mockImplementation(() => ({ data: statsMock })); + useMqttMock = jest.spyOn(Mqtt, 'useMqtt').mockImplementation(() => ({ value: { payload: null } })); }); test('shows an ActivityIndicator if stats are null', async () => { @@ -43,29 +48,31 @@ describe('Debug Route', () => { expect(screen.queryByRole('button', { name: 'Copy to Clipboard' })).toBeInTheDocument(); }); - test('updates the stats on a timeout', async () => { - jest.spyOn(window, 'setTimeout').mockReturnValue(123); - render(); - expect(useStatsMock).toHaveBeenCalledWith(null, null); - jest.advanceTimersByTime(1001); - expect(useStatsMock).toHaveBeenCalledWith(null, 123); - expect(useStatsMock).toHaveBeenCalledTimes(2); + test('updates the stats from mqtt', async () => { + const { rerender } = render(); + expect(useMqttMock).toHaveBeenCalledWith('stats'); + useMqttMock.mockReturnValue({ + value: { + payload: { ...statsMock, detectors: { coral: { ...statsMock.detectors.coral, inference_speed: 42.4242 } } }, + }, + }); + rerender(); + + expect(screen.queryByText('42.4242')).toBeInTheDocument(); }); }); const statsMock = { - data: { + detection_fps: 0.0, + detectors: { coral: { detection_start: 0.0, inference_speed: 8.94, pid: 52 } }, + front: { camera_fps: 5.0, capture_pid: 64, detection_fps: 0.0, pid: 54, process_fps: 0.0, skipped_fps: 0.0 }, + side: { + camera_fps: 6.9, + capture_pid: 71, detection_fps: 0.0, - detectors: { coral: { detection_start: 0.0, inference_speed: 8.94, pid: 52 } }, - front: { camera_fps: 5.0, capture_pid: 64, detection_fps: 0.0, pid: 54, process_fps: 0.0, skipped_fps: 0.0 }, - side: { - camera_fps: 6.9, - capture_pid: 71, - detection_fps: 0.0, - pid: 60, - process_fps: 0.0, - skipped_fps: 0.0, - }, - service: { uptime: 34812, version: '0.8.1-d376f6b' }, + pid: 60, + process_fps: 0.0, + skipped_fps: 0.0, }, + service: { uptime: 34812, version: '0.8.1-d376f6b' }, };