switch to vite

This commit is contained in:
Blake Blackshear
2022-03-05 22:16:31 -06:00
parent 1c9ba11e07
commit 95fe62e141
82 changed files with 4651 additions and 32812 deletions

View File

@@ -4,13 +4,14 @@ import { Match } from 'preact-router/match';
import { memo } from 'preact/compat';
import { ENV } from './env';
import useSWR from 'swr';
import { useMemo } from 'preact/hooks';
import NavigationDrawer, { Destination, Separator } from './components/NavigationDrawer';
export default function Sidebar() {
const { data: config } = useSWR('config');
const cameras = useMemo(() => Object.entries(config.cameras), [config]);
const { birdseye } = config;
if (!config) {
return null;
}
const { cameras, birdseye } = config;
return (
<NavigationDrawer header={<Header />}>
@@ -20,7 +21,7 @@ export default function Sidebar() {
matches ? (
<Fragment>
<Separator />
{cameras.map(([camera]) => (
{Object.entries(cameras).map(([camera]) => (
<Destination key={camera} href={`/cameras/${camera}`} text={camera} />
))}
<Separator />
@@ -33,7 +34,7 @@ export default function Sidebar() {
matches ? (
<Fragment>
<Separator />
{cameras.map(([camera, conf]) => {
{Object.entries(cameras).map(([camera, conf]) => {
if (conf.record.enabled) {
return (
<Destination

View File

@@ -1,25 +1,17 @@
import { h } from 'preact';
import * as Api from '../api';
import * as IDB from 'idb-keyval';
import * as PreactRouter from 'preact-router';
import App from '../App';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('App', () => {
let mockUseConfig;
beforeEach(() => {
jest.spyOn(IDB, 'get').mockImplementation(() => Promise.resolve(undefined));
jest.spyOn(IDB, 'set').mockImplementation(() => Promise.resolve(true));
mockUseConfig = jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
data: { cameras: { front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] } } } },
}));
jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://base-url.local:5000');
jest.spyOn(PreactRouter, 'Router').mockImplementation(() => <div data-testid="router" />);
});
test('shows a loading indicator while loading', async () => {
mockUseConfig.mockReturnValue({ status: 'loading' });
render(<App />);
await screen.findByTestId('app');
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();

View File

@@ -1,7 +1,7 @@
import { h } from 'preact';
import * as Context from '../context';
import AppBar from '../AppBar';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
describe('AppBar', () => {
beforeEach(() => {

View File

@@ -1,40 +1,17 @@
import { h } from 'preact';
import * as Api from '../api';
import * as Context from '../context';
import Sidebar from '../Sidebar';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('Sidebar', () => {
beforeEach(() => {
jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
data: {
cameras: {
front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] }, record: { enabled: true } },
side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] }, record: { enabled: false } },
},
},
}));
jest.spyOn(Context, 'useDrawer').mockImplementation(() => ({ showDrawer: true, setShowDrawer: () => {} }));
});
test('does not render cameras by default', async () => {
render(<Sidebar />);
const { findByText } = render(<Sidebar />);
await findByText('Cameras');
expect(screen.queryByRole('link', { name: 'front' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'side' })).not.toBeInTheDocument();
});
test('render cameras if in camera route', async () => {
window.history.replaceState({}, 'Cameras', '/cameras/front');
render(<Sidebar />);
expect(screen.queryByRole('link', { name: 'front' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'side' })).toBeInTheDocument();
});
test('render cameras if in record route', async () => {
window.history.replaceState({}, 'Front Recordings', '/recording/front');
render(<Sidebar />);
expect(screen.queryByRole('link', { name: 'front' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'side' })).not.toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
import { h } from 'preact';
import * as Mqtt from '../mqtt';
import { ApiProvider, useFetch, useApiHost } from '..';
import { render, screen } from '@testing-library/preact';
import { ApiProvider, useApiHost } from '..';
import { render, screen } from 'testing-library';
describe('useApiHost', () => {
beforeEach(() => {
@@ -21,101 +21,3 @@ describe('useApiHost', () => {
expect(screen.queryByText('http://base-url.local:5000')).toBeInTheDocument();
});
});
function Test() {
const { data, status } = useFetch('/api/tacos');
return (
<div>
<span>{data ? data.returnData : ''}</span>
<span>{status}</span>
</div>
);
}
describe('useFetch', () => {
let fetchSpy;
beforeEach(() => {
jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children);
fetchSpy = jest.spyOn(window, 'fetch').mockImplementation(async (url) => {
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(
<ApiProvider>
<Test />
</ApiProvider>
);
expect(screen.queryByText('loading')).toBeInTheDocument();
expect(screen.queryByText('yep')).not.toBeInTheDocument();
jest.runAllTimers();
await screen.findByText('loaded');
expect(fetchSpy).toHaveBeenCalledWith('http://base-url.local:5000/api/tacos');
expect(screen.queryByText('loaded')).toBeInTheDocument();
expect(screen.queryByText('yep')).toBeInTheDocument();
});
test('sets error if response is not okay', async () => {
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(
<ApiProvider>
<Test />
</ApiProvider>
);
expect(screen.queryByText('loading')).toBeInTheDocument();
jest.runAllTimers();
await screen.findByText('error');
});
test('does not re-fetch if the query has already been made', async () => {
const { rerender } = render(
<ApiProvider>
<Test key={0} />
</ApiProvider>
);
expect(screen.queryByText('loading')).toBeInTheDocument();
expect(screen.queryByText('yep')).not.toBeInTheDocument();
jest.runAllTimers();
await screen.findByText('loaded');
expect(fetchSpy).toHaveBeenCalledWith('http://base-url.local:5000/api/tacos');
rerender(
<ApiProvider>
<Test key={1} />
</ApiProvider>
);
expect(screen.queryByText('loaded')).toBeInTheDocument();
expect(screen.queryByText('yep')).toBeInTheDocument();
jest.runAllTimers();
// once for /api/config, once for /api/tacos
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,7 +1,7 @@
import { h } from 'preact';
import { Mqtt, MqttProvider, useMqtt } from '../mqtt';
import { useCallback, useContext } from 'preact/hooks';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
function Test() {
const { state } = useContext(Mqtt);

View File

@@ -6,11 +6,12 @@ import axios from 'axios';
axios.defaults.baseURL = `${baseUrl}/api/`;
export function ApiProvider({ children }) {
export function ApiProvider({ children, options }) {
return (
<SWRConfig
value={{
fetcher: (path) => axios.get(path).then((res) => res.data),
...options,
}}
>
<MqttWithConfig>{children}</MqttWithConfig>

View File

@@ -1,8 +1,8 @@
import { h } from 'preact';
import { h, JSX } from 'preact';
interface BubbleButtonProps {
variant?: 'primary' | 'secondary';
children?: preact.JSX.Element;
children?: JSX.Element;
disabled?: boolean;
className?: string;
onClick?: () => void;

View File

@@ -98,7 +98,11 @@ const Calendar = ({ onChange, calendarRef, close }) => {
);
useEffect(() => {
setState((prev) => ({ ...prev, selectedDay: todayTimestamp, monthDetails: getMonthDetails(year, month) }));
setState((prev) => ({
...prev,
selectedDay: todayTimestamp,
monthDetails: getMonthDetails(year, month),
}));
}, [year, month, getMonthDetails]);
useEffect(() => {
@@ -150,7 +154,10 @@ const Calendar = ({ onChange, calendarRef, close }) => {
// user has selected a date < after, reset values
if (after === null || day.timestamp < after) {
timeRange = { before: new Date(day.timestamp).setHours(24, 0, 0, 0), after: day.timestamp };
timeRange = {
before: new Date(day.timestamp).setHours(24, 0, 0, 0),
after: day.timestamp,
};
}
// user has selected a date > after
@@ -159,8 +166,8 @@ const Calendar = ({ onChange, calendarRef, close }) => {
after,
before:
day.timestamp >= todayTimestamp
? new Date(todayTimestamp).setHours(24, 0, 0, 0)
: new Date(day.timestamp).setHours(24, 0, 0, 0),
? new Date(todayTimestamp).setHours(24, 0, 0, 0)
: new Date(day.timestamp).setHours(24, 0, 0, 0),
};
}
@@ -243,26 +250,26 @@ const Calendar = ({ onChange, calendarRef, close }) => {
const days =
state.monthDetails &&
state.monthDetails.map((day, idx) => {
return (
<div
onClick={() => onDateClick(day)}
onkeydown={(e) => handleKeydown(e, day, idx)}
ref={(ref) => (keyRef.current[idx] = ref)}
tabIndex={day.month === 0 ? day.date : null}
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
}
return (
<div
onClick={() => onDateClick(day)}
onkeydown={(e) => handleKeydown(e, day, idx)}
ref={(ref) => (keyRef.current[idx] = ref)}
tabIndex={day.month === 0 ? day.date : null}
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
}
${isFirstDayInRange(day) ? ' rounded-l-xl ' : ''}
${isSelectedRange(day) ? ' bg-blue-600 dark:hover:bg-blue-600' : ''}
${isLastDayInRange(day) ? ' rounded-r-xl ' : ''}
${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`}
key={idx}
>
<div className="font-light">
<span className="text-gray-400">{day.date}</span>
</div>
</div>
);
key={idx}
>
<div className="font-light">
<span className="text-gray-400">{day.date}</span>
</div>
</div>
);
});
return (
@@ -314,7 +321,7 @@ const Calendar = ({ onChange, calendarRef, close }) => {
<ArrowRight className="h-2/6" />
</div>
</div>
<div className="w-1/6 relative flex justify-around " tabIndex={104} onClick={() => setYear(1)}>
<div className="w-1/6 relative flex justify-around" tabIndex={104} onClick={() => setYear(1)}>
<div className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800">
<ArrowRightDouble className="h-2/6" />
</div>

View File

@@ -13,8 +13,8 @@ export default function CameraImage({ camera, onload, searchParams = '', stretch
const canvasRef = useRef(null);
const [{ width: availableWidth }] = useResizeObserver(containerRef);
const { name } = config.cameras[camera];
const { width, height } = config.cameras[camera].detect;
const { name } = config ? config.cameras[camera] : '';
const { width, height } = config ? config.cameras[camera].detect : { width: 1, height: 1 };
const aspectRatio = width / height;
const scaledHeight = useMemo(() => {
@@ -37,11 +37,11 @@ export default function CameraImage({ camera, onload, searchParams = '', stretch
);
useEffect(() => {
if (scaledHeight === 0 || !canvasRef.current) {
if (!config || scaledHeight === 0 || !canvasRef.current) {
return;
}
img.src = `${apiHost}/api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`;
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight]);
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);
return (
<div className="relative w-full" ref={containerRef}>

View File

@@ -3,7 +3,7 @@ import Heading from '../Heading';
import type { TimelineEvent } from '../Timeline/TimelineEvent';
interface HistoryHeaderProps {
event: TimelineEvent;
event?: TimelineEvent;
className?: string;
}
export const HistoryHeader = ({ event, className = '' }: HistoryHeaderProps) => {

View File

@@ -15,7 +15,7 @@ interface VideoProperties {
}
interface HistoryVideoProps {
id: string;
id?: string;
isPlaying: boolean;
currentTime: number;
onTimeUpdate?: (event: OnTimeUpdateEvent) => void;
@@ -32,9 +32,13 @@ export const HistoryVideo = ({
onPlay,
}: HistoryVideoProps) => {
const apiHost = useApiHost();
const videoRef = useRef<HTMLVideoElement>();
const [videoHeight, setVideoHeight] = useState<number>(undefined);
const [videoProperties, setVideoProperties] = useState<VideoProperties>(undefined);
const videoRef = useRef<HTMLVideoElement|null>(null);
const [videoHeight, setVideoHeight] = useState<number>(0);
const [videoProperties, setVideoProperties] = useState<VideoProperties>({
posterUrl: '',
videoUrl: '',
height: 0,
});
const currentVideo = videoRef.current;
if (currentVideo && !videoHeight) {
@@ -48,7 +52,7 @@ export const HistoryVideo = ({
const idExists = !isNullOrUndefined(id);
if (idExists) {
if (videoRef.current && !videoRef.current.paused) {
videoRef.current = undefined;
videoRef.current = null;
}
setVideoProperties({
@@ -57,7 +61,11 @@ export const HistoryVideo = ({
height: videoHeight,
});
} else {
setVideoProperties(undefined);
setVideoProperties({
posterUrl: '',
videoUrl: '',
height: 0,
});
}
}, [id, videoHeight, videoRef, apiHost]);
@@ -78,7 +86,7 @@ export const HistoryVideo = ({
const video = videoRef.current;
const videoExists = !isNullOrUndefined(video);
if (videoExists) {
if (video && videoExists) {
if (videoIsPlaying) {
attemptPlayVideo(video);
} else {
@@ -91,7 +99,7 @@ export const HistoryVideo = ({
const video = videoRef.current;
const videoExists = !isNullOrUndefined(video);
const hasSeeked = currentTime >= 0;
if (videoExists && hasSeeked) {
if (video && videoExists && hasSeeked) {
video.currentTime = currentTime;
}
}, [currentTime, videoRef]);

View File

@@ -8,7 +8,7 @@ import type { TimelineEvent } from '../Timeline/TimelineEvent';
import { HistoryHeader } from './HistoryHeader';
import { HistoryVideo } from './HistoryVideo';
export default function HistoryViewer({ camera }) {
export default function HistoryViewer({ camera }: {camera: string}) {
const searchParams = {
before: null,
after: null,
@@ -18,17 +18,17 @@ export default function HistoryViewer({ camera }) {
};
// TODO: refactor
const eventsFetcher = (path, params) => {
const eventsFetcher = (path: string, params: {[name:string]: string|number}) => {
params = { ...params, include_thumbnails: 0, limit: 500 };
return axios.get(path, { params }).then((res) => res.data);
return axios.get<TimelineEvent[]>(path, { params }).then((res) => res.data);
};
const { data: events } = useSWR(['events', searchParams], eventsFetcher);
const [timelineEvents, setTimelineEvents] = useState<TimelineEvent[]>(undefined);
const [currentEvent, setCurrentEvent] = useState<TimelineEvent>(undefined);
const [isPlaying, setIsPlaying] = useState(undefined);
const [currentTime, setCurrentTime] = useState<number>(undefined);
const [timelineEvents, setTimelineEvents] = useState<TimelineEvent[]>([]);
const [currentEvent, setCurrentEvent] = useState<TimelineEvent>();
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [currentTime, setCurrentTime] = useState<number>(new Date().getTime());
useEffect(() => {
if (events) {

View File

@@ -16,7 +16,7 @@ interface TimelineProps {
}
export default function Timeline({ events, isPlaying, onChange, onPlayPause }: TimelineProps) {
const timelineContainerRef = useRef<HTMLDivElement>(undefined);
const timelineContainerRef = useRef<HTMLDivElement>(null);
const [timeline, setTimeline] = useState<TimelineEventBlock[]>([]);
const [disabledControls, setDisabledControls] = useState<DisabledControls>({
@@ -24,10 +24,10 @@ export default function Timeline({ events, isPlaying, onChange, onPlayPause }: T
next: true,
previous: false,
});
const [timelineOffset, setTimelineOffset] = useState<number | undefined>(undefined);
const [markerTime, setMarkerTime] = useState<Date | undefined>(undefined);
const [timelineOffset, setTimelineOffset] = useState<number>(0);
const [markerTime, setMarkerTime] = useState<Date>(new Date());
const [currentEvent, setCurrentEvent] = useState<TimelineEventBlock | undefined>(undefined);
const [scrollTimeout, setScrollTimeout] = useState<NodeJS.Timeout | undefined>(undefined);
const [scrollTimeout, setScrollTimeout] = useState<NodeJS.Timeout>();
const [scrollPermission, setScrollPermission] = useState<ScrollPermission>({
allowed: true,
resetAfterSeeked: false,
@@ -51,7 +51,7 @@ export default function Timeline({ events, isPlaying, onChange, onPlayPause }: T
);
const scrollToEvent = useCallback(
(event, offset = 0) => {
(event: TimelineEventBlock, offset = 0) => {
scrollToPosition(event.positionX + offset - timelineOffset);
},
[timelineOffset, scrollToPosition]
@@ -137,7 +137,9 @@ export default function Timeline({ events, isPlaying, onChange, onPlayPause }: T
};
const waitForSeekComplete = (markerTime: Date) => {
clearTimeout(scrollTimeout);
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
setScrollTimeout(setTimeout(() => seekCompleteHandler(markerTime), 150));
};
@@ -161,11 +163,12 @@ export default function Timeline({ events, isPlaying, onChange, onPlayPause }: T
const firstTimelineEventStartTime = firstTimelineEvent.startTime.getTime();
return new Date(firstTimelineEventStartTime + scrollPosition * 1000);
}
return new Date();
}, [timeline, timelineContainerRef]);
useEffect(() => {
if (timelineContainerRef) {
const timelineContainerWidth = timelineContainerRef.current.offsetWidth;
const timelineContainerWidth = timelineContainerRef.current?.offsetWidth || 0;
const offset = Math.round(timelineContainerWidth / 2);
setTimelineOffset(offset);
}
@@ -180,7 +183,7 @@ export default function Timeline({ events, isPlaying, onChange, onPlayPause }: T
);
const onPlayPauseHandler = (isPlaying: boolean) => {
onPlayPause(isPlaying);
onPlayPause && onPlayPause(isPlaying);
};
const onPreviousHandler = () => {

View File

@@ -41,6 +41,7 @@ export const TimelineBlocks = ({ timeline, firstBlockOffset, onEventClick }: Tim
</div>
);
}
return <div />
}, [timeline, onEventClick, firstBlockOffset]);
return timelineEventBlocks;

View File

@@ -1,7 +1,7 @@
import type { TimelineEvent } from './TimelineEvent';
export interface TimelineChangeEvent {
timelineEvent: TimelineEvent;
timelineEvent?: TimelineEvent;
markerTime: Date;
seekComplete: boolean;
}

View File

@@ -1,6 +1,6 @@
import { h } from 'preact';
import ActivityIndicator from '../ActivityIndicator';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('ActivityIndicator', () => {
test('renders an ActivityIndicator with default size md', async () => {

View File

@@ -1,7 +1,7 @@
import { h } from 'preact';
import { DrawerProvider } from '../../context';
import AppBar from '../AppBar';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
import { useRef } from 'preact/hooks';
function Title() {

View File

@@ -1,6 +1,6 @@
import { h } from 'preact';
import AutoUpdatingCameraImage from '../AutoUpdatingCameraImage';
import { screen, render } from '@testing-library/preact';
import { screen, render } from 'testing-library';
let mockOnload;
jest.mock('../CameraImage', () => {
@@ -34,9 +34,9 @@ describe('AutoUpdatingCameraImage', () => {
test('on load, sets a new cache key to search params', async () => {
dateNowSpy.mockReturnValueOnce(100).mockReturnValueOnce(200).mockReturnValueOnce(300);
render(<AutoUpdatingCameraImage camera="tacos" searchParams="foo" />);
render(<AutoUpdatingCameraImage camera="front" searchParams="foo" />);
mockOnload();
jest.runAllTimers();
expect(screen.queryByText('cache=100&foo')).toBeInTheDocument();
await screen.findByText('cache=100&foo');
expect(screen.getByText('cache=100&foo')).toBeInTheDocument();
});
});

View File

@@ -1,6 +1,6 @@
import { h } from 'preact';
import Button from '../Button';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('Button', () => {
test('renders children', async () => {

View File

@@ -1,15 +1,10 @@
import { h } from 'preact';
import * as Api from '../../api';
import * as Hooks from '../../hooks';
import CameraImage from '../CameraImage';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('CameraImage', () => {
beforeEach(() => {
jest.spyOn(Api, 'useConfig').mockImplementation(() => {
return { data: { cameras: { front: { name: 'front', detect: { width: 1280, height: 720 } } } } };
});
jest.spyOn(Api, 'useApiHost').mockReturnValue('http://base-url.local:5000');
jest.spyOn(Hooks, 'useResizeObserver').mockImplementation(() => [{ width: 0 }]);
});
@@ -17,24 +12,4 @@ describe('CameraImage', () => {
render(<CameraImage camera="front" />);
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
});
test('creates a scaled canvas using the available width & height, preserving camera aspect ratio', async () => {
jest.spyOn(Hooks, 'useResizeObserver').mockReturnValueOnce([{ width: 720 }]);
render(<CameraImage camera="front" />);
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
const canvas = screen.queryByTestId('cameraimage-canvas');
expect(canvas).toHaveAttribute('height', '405');
expect(canvas).toHaveAttribute('width', '720');
});
test('allows camera image to stretch to available space', async () => {
jest.spyOn(Hooks, 'useResizeObserver').mockReturnValueOnce([{ width: 1400 }]);
render(<CameraImage camera="front" stretch />);
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
const canvas = screen.queryByTestId('cameraimage-canvas');
expect(canvas).toHaveAttribute('height', '787');
expect(canvas).toHaveAttribute('width', '1400');
});
});

View File

@@ -1,6 +1,6 @@
import { h } from 'preact';
import Card from '../Card';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('Card', () => {
test('renders a Card with media', async () => {

View File

@@ -1,6 +1,6 @@
import { h } from 'preact';
import Dialog from '../Dialog';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('Dialog', () => {
let portal;

View File

@@ -1,6 +1,6 @@
import { h } from 'preact';
import Heading from '../Heading';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('Heading', () => {
test('renders content with default size', async () => {

View File

@@ -1,6 +1,6 @@
import { h } from 'preact';
import Link from '../Link';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('Link', () => {
test('renders a link', async () => {

View File

@@ -1,6 +1,6 @@
import { h } from 'preact';
import Menu, { MenuItem } from '../Menu';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
import { useRef } from 'preact/hooks';
describe('Menu', () => {

View File

@@ -1,7 +1,7 @@
import { h } from 'preact';
import * as Context from '../../context';
import NavigationDrawer, { Destination } from '../NavigationDrawer';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
describe('NavigationDrawer', () => {
let useDrawer, setShowDrawer;
@@ -49,6 +49,7 @@ describe('Destination', () => {
});
test('dismisses the drawer moments after being clicked', async () => {
jest.useFakeTimers();
render(
<NavigationDrawer>
<Destination href="/tacos" text="Tacos" />

View File

@@ -1,6 +1,6 @@
import { h } from 'preact';
import Prompt from '../Prompt';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
describe('Prompt', () => {
let portal;
@@ -16,7 +16,7 @@ describe('Prompt', () => {
});
test('renders to a portal', async () => {
render(<Prompt title='Tacos' text='This is the dialog' />);
render(<Prompt title="Tacos" text="This is the dialog" />);
expect(screen.getByText('Tacos')).toBeInTheDocument();
expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull();
});
@@ -29,7 +29,7 @@ describe('Prompt', () => {
{ color: 'red', text: 'Delete' },
{ text: 'Okay', onClick: handleClick },
]}
title='Tacos'
title="Tacos"
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Okay' }));

View File

@@ -1,7 +1,7 @@
import { h, createRef } from 'preact';
import RelativeModal from '../RelativeModal';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
describe('RelativeModal', () => {
test('keeps tab focus', async () => {

View File

@@ -1,6 +1,6 @@
import { h } from 'preact';
import Select from '../Select';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
describe('Select', () => {
test('on focus, shows a menu', async () => {

View File

@@ -1,6 +1,6 @@
import { h } from 'preact';
import Switch from '../Switch';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
describe('Switch', () => {
test('renders a hidden checkbox', async () => {

View File

@@ -1,6 +1,6 @@
import { h } from 'preact';
import TextField from '../TextField';
import { fireEvent, render, screen } from '@testing-library/preact';
import { render, screen, fireEvent } from 'testing-library';
describe('TextField', () => {
test('can render a leading icon', async () => {
@@ -20,20 +20,6 @@ describe('TextField', () => {
expect(icons[1]).toHaveAttribute('data-testid', 'icon-trailing');
});
test('focuses and blurs', async () => {
const handleFocus = jest.fn();
const handleBlur = jest.fn();
render(<TextField label="Tacos" onFocus={handleFocus} onBlur={handleBlur} />);
fireEvent.focus(screen.getByRole('textbox'));
expect(handleFocus).toHaveBeenCalled();
expect(screen.getByText('Tacos').classList.contains('-translate-y-2')).toBe(true);
fireEvent.blur(screen.getByRole('textbox'));
expect(handleBlur).toHaveBeenCalled();
expect(screen.getByText('Tacos').classList.contains('-translate-y-2')).toBe(false);
});
test('onChange updates the value', async () => {
const handleChangeText = jest.fn();
render(<TextField label="Tacos" onChangeText={handleChangeText} />);

View File

@@ -1,19 +1,19 @@
import { h, createRef } from 'preact';
import Tooltip from '../Tooltip';
import { render, screen } from '@testing-library/preact';
import { render, screen } from 'testing-library';
describe('Tooltip', () => {
test('renders in a relative position', async () => {
jest
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
// relativeTo
// relativeTo
.mockReturnValueOnce({
x: 100,
y: 100,
width: 50,
height: 10,
})
// tooltip
// tooltip
.mockReturnValueOnce({ width: 40, height: 15 });
const ref = createRef();
@@ -34,14 +34,14 @@ describe('Tooltip', () => {
window.innerWidth = 1024;
jest
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
// relativeTo
// relativeTo
.mockReturnValueOnce({
x: 1000,
y: 100,
width: 24,
height: 10,
})
// tooltip
// tooltip
.mockReturnValueOnce({ width: 50, height: 15 });
const ref = createRef();
@@ -61,14 +61,14 @@ describe('Tooltip', () => {
test('if too far left, renders to the right', async () => {
jest
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
// relativeTo
// relativeTo
.mockReturnValueOnce({
x: 0,
y: 100,
width: 24,
height: 10,
})
// tooltip
// tooltip
.mockReturnValueOnce({ width: 50, height: 15 });
const ref = createRef();
@@ -89,14 +89,14 @@ describe('Tooltip', () => {
window.scrollY = 90;
jest
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
// relativeTo
// relativeTo
.mockReturnValueOnce({
x: 100,
y: 100,
width: 24,
height: 10,
})
// tooltip
// tooltip
.mockReturnValueOnce({ width: 50, height: 15 });
const ref = createRef();

View File

@@ -1,8 +1,9 @@
import { h } from 'preact';
import * as IDB from 'idb-keyval';
import { DarkModeProvider, useDarkMode, usePersistence } from '..';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen } from 'testing-library';
import { useCallback } from 'preact/hooks';
import * as Mqtt from '../../api/mqtt';
function DarkModeChecker() {
const { currentMode } = useDarkMode();
@@ -16,6 +17,8 @@ describe('DarkMode', () => {
get: jest.spyOn(IDB, 'get').mockImplementation(() => Promise.resolve(undefined)),
set: jest.spyOn(IDB, 'set').mockImplementation(() => Promise.resolve(true)),
};
jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children);
});
test('uses media by default', async () => {
@@ -148,8 +151,6 @@ describe('usePersistence', () => {
my-default
</div>
`);
jest.runAllTimers();
});
test('updates with the previously-persisted value', async () => {

View File

@@ -1,2 +1,2 @@
export const ENV = import.meta.env.MODE;
export const API_HOST = import.meta.env.SNOWPACK_PUBLIC_API_HOST;
export const API_HOST = ENV === "production" ? "" : "http://localhost:5000";

View File

@@ -1,6 +1,6 @@
import App from './App';
import { ApiProvider } from './api';
import { h, render } from 'preact';
import { render } from 'preact';
import 'preact/devtools';
import './index.css';
@@ -8,5 +8,5 @@ render(
<ApiProvider>
<App />
</ApiProvider>,
document.getElementById('root')
document.getElementById('app') as HTMLElement
);

5
web/src/preact.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import JSX = preact.JSX
// temporary until codebase is converted to typescript
declare module "*";

View File

@@ -1,5 +1,6 @@
import { h, Fragment } from 'preact';
import AutoUpdatingCameraImage from '../components/AutoUpdatingCameraImage';
import ActivityIndicator from '../components/ActivityIndicator';
import JSMpegPlayer from '../components/JSMpegPlayer';
import Button from '../components/Button';
import Card from '../components/Card';
@@ -22,7 +23,9 @@ export default function Camera({ camera }) {
const [viewMode, setViewMode] = useState('live');
const cameraConfig = config?.cameras[camera];
const liveWidth = Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height));
const liveWidth = cameraConfig
? Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height))
: 0;
const [options, setOptions] = usePersistence(`${camera}-feed`, emptyObject);
const handleSetOption = useCallback(
@@ -48,6 +51,10 @@ export default function Camera({ camera }) {
setShowSettings(!showSettings);
}, [showSettings, setShowSettings]);
if (!cameraConfig) {
return <ActivityIndicator />;
}
const optionContent = showSettings ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<Switch
@@ -93,8 +100,7 @@ export default function Camera({ camera }) {
</div>
</Fragment>
);
}
else if (viewMode === 'debug') {
} else if (viewMode === 'debug') {
player = (
<Fragment>
<div>

View File

@@ -67,13 +67,13 @@ export default function Events({ path, ...props }) {
const { data: config } = useSWR('config');
const cameras = useMemo(() => Object.keys(config.cameras), [config]);
const cameras = useMemo(() => Object.keys(config?.cameras || {}), [config]);
const zones = useMemo(
() =>
Object.values(config.cameras)
Object.values(config?.cameras || {})
.reduce((memo, camera) => {
memo = memo.concat(Object.keys(camera.zones));
memo = memo.concat(Object.keys(camera?.zones || {}));
return memo;
}, [])
.filter((value, i, self) => self.indexOf(value) === i),
@@ -81,11 +81,11 @@ export default function Events({ path, ...props }) {
);
const labels = useMemo(() => {
return Object.values(config.cameras)
return Object.values(config?.cameras || {})
.reduce((memo, camera) => {
memo = memo.concat(camera.objects?.track || []);
memo = memo.concat(camera?.objects?.track || []);
return memo;
}, config.objects?.track || [])
}, config?.objects?.track || [])
.filter((value, i, self) => self.indexOf(value) === i);
}, [config]);
@@ -123,6 +123,7 @@ export default function Events({ path, ...props }) {
const handleSelectDateRange = useCallback(
(dates) => {
console.log(dates);
setSearchParams({ ...searchParams, before: dates.before, after: dates.after });
setShowDatePicker(false);
},

View File

@@ -1,10 +1,10 @@
import { h } from 'preact';
import * as AutoUpdatingCameraImage from '../../components/AutoUpdatingCameraImage';
import * as Api from '../../api';
import * as Context from '../../context';
import * as Mqtt from '../../api/mqtt';
import Camera from '../Camera';
import * as JSMpegPlayer from '../../components/JSMpegPlayer';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen, waitForElementToBeRemoved } from 'testing-library';
describe('Camera Route', () => {
let mockUsePersistence, mockSetOptions;
@@ -12,16 +12,13 @@ describe('Camera Route', () => {
beforeEach(() => {
mockSetOptions = jest.fn();
mockUsePersistence = jest.spyOn(Context, 'usePersistence').mockImplementation(() => [{}, mockSetOptions]);
jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
data: { cameras: { front: { name: 'front', detect: {width: 1280, height: 720}, live: {height: 720}, objects: { track: ['taco', 'cat', 'dog'] } } } },
}));
jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://base-url.local:5000');
jest.spyOn(AutoUpdatingCameraImage, 'default').mockImplementation(({ searchParams }) => {
return <div data-testid="mock-image">{searchParams.toString()}</div>;
});
jest.spyOn(JSMpegPlayer, 'default').mockImplementation(() => {
return <div data-testid="mock-jsmpeg" />;
});
jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children);
});
test('reads camera feed options from persistence', async () => {
@@ -39,6 +36,8 @@ describe('Camera Route', () => {
render(<Camera camera="front" />);
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
fireEvent.click(screen.queryByText('Debug'));
fireEvent.click(screen.queryByText('Show Options'));
expect(screen.queryByTestId('mock-image')).toHaveTextContent(
@@ -48,6 +47,7 @@ describe('Camera Route', () => {
test('updates camera feed options to persistence', async () => {
mockUsePersistence
.mockReturnValueOnce([{}, mockSetOptions])
.mockReturnValueOnce([{}, mockSetOptions])
.mockReturnValueOnce([{}, mockSetOptions])
.mockReturnValueOnce([{ bbox: true }, mockSetOptions])
@@ -55,13 +55,15 @@ describe('Camera Route', () => {
render(<Camera camera="front" />);
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
fireEvent.click(screen.queryByText('Debug'));
fireEvent.click(screen.queryByText('Show Options'));
fireEvent.change(screen.queryByTestId('bbox-input'), { target: { checked: true } });
fireEvent.change(screen.queryByTestId('timestamp-input'), { target: { checked: true } });
fireEvent.click(screen.queryByText('Hide Options'));
expect(mockUsePersistence).toHaveBeenCalledTimes(4);
expect(mockUsePersistence).toHaveBeenCalledTimes(5);
expect(mockSetOptions).toHaveBeenCalledTimes(2);
expect(mockSetOptions).toHaveBeenCalledWith({ bbox: true, timestamp: true });
expect(screen.queryByTestId('mock-image')).toHaveTextContent('bbox=1&timestamp=1');

View File

@@ -1,30 +1,16 @@
import { h } from 'preact';
import * as Api from '../../api';
import * as CameraImage from '../../components/CameraImage';
import * as Mqtt from '../../api/mqtt';
import Cameras from '../Cameras';
import { fireEvent, render, screen } from '@testing-library/preact';
import { fireEvent, render, screen, waitForElementToBeRemoved } from 'testing-library';
describe('Cameras Route', () => {
let useConfigMock;
beforeEach(() => {
useConfigMock = jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
data: {
cameras: {
front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] }, record: { enabled: true } },
side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] }, record: { enabled: false } },
},
},
status: 'loaded',
}));
jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://base-url.local:5000');
jest.spyOn(CameraImage, 'default').mockImplementation(() => <div data-testid="camera-image" />);
jest.spyOn(Mqtt, 'useMqtt').mockImplementation(() => ({ value: { payload: 'OFF' }, send: jest.fn() }));
});
test('shows an ActivityIndicator if not yet loaded', async () => {
useConfigMock.mockReturnValueOnce(() => ({ status: 'loading' }));
render(<Cameras />);
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
});
@@ -32,7 +18,7 @@ describe('Cameras Route', () => {
test('shows cameras', async () => {
render(<Cameras />);
expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument();
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
expect(screen.queryByText('front')).toBeInTheDocument();
expect(screen.queryByText('front').closest('a')).toHaveAttribute('href', '/cameras/front');
@@ -44,7 +30,7 @@ describe('Cameras Route', () => {
test('shows recordings link', async () => {
render(<Cameras />);
expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument();
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
expect(screen.queryAllByText('Recordings')).toHaveLength(2);
});
@@ -65,6 +51,8 @@ describe('Cameras Route', () => {
render(<Cameras />);
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
fireEvent.click(screen.getAllByLabelText('Toggle detect off')[0]);
expect(sendDetect).toHaveBeenCalledWith('OFF');
expect(sendDetect).toHaveBeenCalledTimes(1);

View File

@@ -1,41 +1,19 @@
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';
import { render, screen, waitForElementToBeRemoved } from 'testing-library';
describe('Debug Route', () => {
let useStatsMock, useMqttMock;
beforeEach(() => {
jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
data: {
service: {
version: '0.8.3',
},
cameras: {
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(() => ({ data: statsMock }));
useMqttMock = jest.spyOn(Mqtt, 'useMqtt').mockImplementation(() => ({ value: { payload: null } }));
});
beforeEach(() => {});
test('shows an ActivityIndicator if stats are null', async () => {
useStatsMock.mockReturnValue({ data: null });
render(<Debug />);
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
});
test('shows stats and config', async () => {
render(<Debug />);
expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument();
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
expect(screen.queryByTestId('detectors')).toBeInTheDocument();
expect(screen.queryByText('coral')).toBeInTheDocument();
@@ -47,32 +25,4 @@ describe('Debug Route', () => {
expect(screen.queryByText('Config')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Copy to Clipboard' })).toBeInTheDocument();
});
test('updates the stats from mqtt', async () => {
const { rerender } = render(<Debug />);
expect(useMqttMock).toHaveBeenCalledWith('stats');
useMqttMock.mockReturnValue({
value: {
payload: { ...statsMock, detectors: { coral: { ...statsMock.detectors.coral, inference_speed: 42.4242 } } },
},
});
rerender(<Debug />);
expect(screen.queryByText('42.4242')).toBeInTheDocument();
});
});
const statsMock = {
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' },
};

View File

@@ -1,28 +1,9 @@
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';
import { render, screen, waitForElementToBeRemoved } from 'testing-library';
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()]);
});
beforeEach(() => {});
test('shows an ActivityIndicator if not yet loaded', async () => {
render(<Events limit={5} path="/events" />);
@@ -30,53 +11,10 @@ describe('Events Route', () => {
});
test('does not show ActivityIndicator after loaded', async () => {
useEventsMock.mockReturnValue({ data: mockEvents, status: 'loaded' });
render(<Events limit={5} path="/events" />);
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
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,
has_clip: true,
has_snapshot: true,
id: i,
label: 'person',
start_time: 1613257326 + i,
top_score: Math.random(),
zones: ['front_patio'],
thumbnail: '/9j/4aa...',
}));

View File

@@ -70,4 +70,5 @@ export const getTimelineWidthFromBlocks = (blocks: TimelineEventBlock[], offset:
const timelineDurationLong = epochToLong(endTimeEpoch - startTimeEpoch);
return timelineDurationLong + offset * 2;
}
return 0;
};

1
web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />