Optimize Python Tests (#24823)

* Optimize tests

* Address comments

* Fix test
This commit is contained in:
Nicolas Mowen
2026-10-29 12:39:21 -05:00
committed by GitHub
parent bbf23ccce7
commit 3dd59d9a44
16 changed files with 377 additions and 59 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {
"version": "2.7.1",
"resolved": "ghcr.io/devcontainers/features/common-utils@sha256:e76a6a8a35d1ad824daad1540725e0ec9f8ed2dc00d2850e990a9830013c7d08",
"integrity": "sha256:e76a6a8a35d1ad824daad1540725e0ec9f8ed2dc00d2850e990a9830013c7d08"
}
}
}
+1 -1
View File
@@ -125,4 +125,4 @@ jobs:
- name: Check API spec is up to date - name: Check API spec is up to date
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check" run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check"
- name: Run unit tests in devcontainer - name: Run unit tests in devcontainer
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m unittest" run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m frigate.test"
+4 -4
View File
@@ -36,7 +36,7 @@ When reviewing code, do NOT comment on:
- **Formatting**: Ruff (configured in `pyproject.toml`) - **Formatting**: Ruff (configured in `pyproject.toml`)
- **Linting**: Ruff with rules defined in project config - **Linting**: Ruff with rules defined in project config
- **Type Checking**: Use type hints consistently - **Type Checking**: Use type hints consistently
- **Testing**: unittest framework - use `python3 -u -m unittest` to run tests - **Testing**: unittest framework - use `python3 -u -m frigate.test` to run the suite in parallel groups
- **Language**: American English for all code, comments, and documentation - **Language**: American English for all code, comments, and documentation
- **Punctuation**: Do not use em dashes in documentation, comments, or strings; reword with standard punctuation (commas, colons, parentheses, or separate sentences) - **Punctuation**: Do not use em dashes in documentation, comments, or strings; reword with standard punctuation (commas, colons, parentheses, or separate sentences)
@@ -196,7 +196,7 @@ Key rules enforced:
### Backend Testing ### Backend Testing
- **Framework**: Python unittest - **Framework**: Python unittest
- **Run Command**: `python3 -u -m unittest` - **Run Command**: `python3 -u -m frigate.test` (parallel groups; plain `python3 -u -m unittest` runs serially)
- **Location**: `frigate/test/` - **Location**: `frigate/test/`
- **Coverage**: Aim for comprehensive test coverage of core functionality - **Coverage**: Aim for comprehensive test coverage of core functionality
- **Pattern**: Use `TestCase` classes with descriptive test method names - **Pattern**: Use `TestCase` classes with descriptive test method names
@@ -219,8 +219,8 @@ Key rules enforced:
### Python Backend ### Python Backend
```bash ```bash
# Run all tests # Run all tests in parallel groups (-j to set the number of jobs)
python3 -u -m unittest python3 -u -m frigate.test
# Run specific test file # Run specific test file
python3 -u -m unittest frigate.test.test_ffmpeg_presets python3 -u -m unittest frigate.test.test_ffmpeg_presets
+2 -2
View File
@@ -64,7 +64,7 @@ Pull requests that appear to be unreviewed AI output will be closed without revi
- **Python** — use modern language features (type hints, pattern matching, f-strings, dataclasses) - **Python** — use modern language features (type hints, pattern matching, f-strings, dataclasses)
- **Formatting**: Ruff (configured in `pyproject.toml`) - **Formatting**: Ruff (configured in `pyproject.toml`)
- **Linting**: Ruff - **Linting**: Ruff
- **Testing**: `python3 -u -m unittest` - **Testing**: `python3 -u -m frigate.test` (runs the suite in parallel groups)
- **Logging**: Use module-level `logger = logging.getLogger(__name__)` with lazy formatting - **Logging**: Use module-level `logger = logging.getLogger(__name__)` with lazy formatting
- **Async**: All external I/O must be async. No blocking calls in async functions. - **Async**: All external I/O must be async. No blocking calls in async functions.
- **Error handling**: Use specific exception types. Keep try blocks minimal. - **Error handling**: Use specific exception types. Keep try blocks minimal.
@@ -82,7 +82,7 @@ Pull requests that appear to be unreviewed AI output will be closed without revi
```bash ```bash
# Python # Python
python3 -u -m unittest # Run all tests python3 -u -m frigate.test # Run all tests in parallel groups
python3 -u -m unittest frigate.test.test_ffmpeg_presets # Run specific test python3 -u -m unittest frigate.test.test_ffmpeg_presets # Run specific test
ruff format frigate # Format ruff format frigate # Format
ruff check frigate # Lint ruff check frigate # Lint
+1 -1
View File
@@ -54,7 +54,7 @@ run: local
run_tests: local run_tests: local
docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \ docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \
python3 -u -m unittest python3 -u -m frigate.test
docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \ docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \
python3 -u -m mypy --config-file frigate/mypy.ini frigate python3 -u -m mypy --config-file frigate/mypy.ini frigate
+2 -2
View File
@@ -94,7 +94,7 @@ After closing VS Code, you may still have containers running. To close everythin
GitHub will execute unit tests on new PRs. You must ensure that all tests pass. GitHub will execute unit tests on new PRs. You must ensure that all tests pass.
```shell ```shell
python3 -u -m unittest python3 -u -m frigate.test
``` ```
#### FFMPEG Hardware Acceleration #### FFMPEG Hardware Acceleration
@@ -207,7 +207,7 @@ npm run lint
- Ensure the backend [unit tests](#unit-tests) pass. Your PR cannot be merged unless tests pass. - Ensure the backend [unit tests](#unit-tests) pass. Your PR cannot be merged unless tests pass.
```shell ```shell
python3 -u -m unittest python3 -u -m frigate.test
``` ```
- Ensure the end-to-end tests pass. They run in Playwright against a production build with mocked API data, so they don't need a running Frigate instance. Add or update tests in `web/e2e/specs/` when you change UI behavior. - Ensure the end-to-end tests pass. They run in Playwright against a production build with mocked API data, so they don't need a running Frigate instance. Add or update tests in `web/e2e/specs/` when you change UI behavior.
+11
View File
@@ -0,0 +1,11 @@
from frigate.config.config import stream_info_retriever
def _unreachable_stream_info(ffmpeg, path: str) -> dict:
return {"width": 0, "height": 0, "fourcc": None}
# Validating a camera without detect dimensions probes its stream. Test streams
# are unreachable, so return the failed probe result instead of waiting ~12s per
# stream for ffprobe to time out
stream_info_retriever.get_stream_info = _unreachable_stream_info
+278
View File
@@ -0,0 +1,278 @@
"""Run the unit test suite in parallel groups.
Test modules are split into groups balanced by test count, similar to Home
Assistant's split_tests.py, and each group runs in its own unittest process.
Usage (from the repo root):
python3 -u -m frigate.test [-j JOBS] [--groups GROUPS] [--timeout SECONDS]
"""
import argparse
import math
import os
import re
import signal
import subprocess
import sys
import tempfile
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
TEST_METHOD = re.compile(r"^\s+(?:async )?def test", re.MULTILINE)
RAN_TESTS = re.compile(r"^Ran (\d+) tests? in", re.MULTILINE)
# Each group pays for its own interpreter and imports, so smaller groups only
# help when there are more cores than groups
TESTS_PER_GROUP = 250
# Each group runs in its own process group, which ctrl+c in the terminal does
# not reach, so the runner passes the interrupt on to these
interrupted = threading.Event()
running_groups: set[int] = set()
@dataclass
class TestGroup:
index: int
modules: list[str] = field(default_factory=list)
weight: int = 0
@dataclass
class GroupResult:
group: TestGroup
returncode: int
output: str
duration: float
@property
def tests_run(self) -> int:
"""Number of tests unittest reported running, 0 if it never finished."""
match = RAN_TESTS.search(self.output)
return int(match.group(1)) if match else 0
def discover_modules() -> dict[str, int]:
"""Find test modules the same way unittest discovery does.
Returns:
Module name mapped to the number of test methods it defines
"""
modules: dict[str, int] = {}
for path in sorted((REPO_ROOT / "frigate").rglob("test*.py")):
relative = path.relative_to(REPO_ROOT)
# discovery only descends into packages
if not all(
(REPO_ROOT / parent / "__init__.py").exists()
for parent in relative.parents
if parent != Path(".")
):
continue
name = ".".join(relative.with_suffix("").parts)
modules[name] = len(TEST_METHOD.findall(path.read_text()))
return modules
def split_groups(modules: dict[str, int], count: int) -> list[TestGroup]:
"""Split modules into groups with a similar number of tests.
Largest modules are placed first, each into the currently lightest group.
"""
groups = [TestGroup(index) for index in range(1, count + 1)]
for name, weight in sorted(modules.items(), key=lambda m: m[1], reverse=True):
group = min(groups, key=lambda g: g.weight)
group.modules.append(name)
group.weight += weight
return [group for group in groups if group.modules]
def signal_group(pgid: int, sig: signal.Signals) -> bool:
"""Send a signal to every process in a process group.
Returns:
False if no process is left in the group
"""
try:
os.killpg(pgid, sig)
except ProcessLookupError:
return False
return True
def stop_group(pgid: int) -> None:
"""Stop the processes a group's tests left running.
They get a moment to exit before being killed, so the multiprocessing
resource tracker, which ignores SIGTERM, can remove leaked shared memory
once the processes holding it have exited.
"""
if signal_group(pgid, signal.SIGTERM):
time.sleep(2)
signal_group(pgid, signal.SIGKILL)
def run_group(group: TestGroup, timeout: float) -> GroupResult:
"""Run a group's modules in a unittest process of its own.
Args:
group: The modules to run
timeout: Seconds before the group is stopped
Returns:
The exit code and combined output of the unittest process
"""
start = time.monotonic()
# a file instead of a pipe so a process left behind by a test can't keep
# the group from finishing by holding the pipe open
with tempfile.TemporaryFile("w+") as output:
proc = subprocess.Popen(
[sys.executable, "-u", "-X", "faulthandler", "-m", "unittest"]
+ group.modules,
cwd=REPO_ROOT,
# a group in the background that read the terminal would be
# stopped, and a stopped process doesn't act on SIGABRT
stdin=subprocess.DEVNULL,
stdout=output,
stderr=subprocess.STDOUT,
# a process group of its own so the processes its tests start can
# be stopped with it
process_group=0,
)
running_groups.add(proc.pid)
# the runner may have been interrupted while this group was starting
if interrupted.is_set():
signal_group(proc.pid, signal.SIGINT)
timed_out = False
try:
proc.wait(timeout)
except subprocess.TimeoutExpired:
# faulthandler prints the stack of every thread on SIGABRT, which
# shows where the group is stuck
timed_out = True
proc.send_signal(signal.SIGABRT)
proc.wait()
# aborting skips the cleanup of the running test, which would
# have stopped the processes it started
stop_group(proc.pid)
running_groups.discard(proc.pid)
output.seek(0)
text = output.read()
if timed_out:
text += f"\nGroup timed out after {timeout:.0f}s\n"
return GroupResult(group, proc.returncode, text, time.monotonic() - start)
def main() -> int:
"""Split the tests into groups, run them in parallel, and report the results.
Returns:
The exit code, 1 if any group failed
"""
cpus = os.cpu_count() or 1
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"-j",
"--jobs",
type=int,
default=cpus,
help=f"number of groups to run at once (default: {cpus})",
)
parser.add_argument(
"--groups",
type=int,
help=(
"number of groups to split the tests into (default: one per job, "
f"or more to keep groups under about {TESTS_PER_GROUP} tests)"
),
)
parser.add_argument(
"--timeout",
type=float,
default=600,
help="seconds before a group is stopped and reported as failed",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="print the output of passing groups as well as failing ones",
)
args = parser.parse_args()
modules = discover_modules()
group_count = args.groups or max(
args.jobs, math.ceil(sum(modules.values()) / TESTS_PER_GROUP)
)
groups = split_groups(modules, group_count)
print(f"Running {len(groups)} test groups with {args.jobs} jobs", flush=True)
start = time.monotonic()
failed: list[GroupResult] = []
total_tests = 0
with ThreadPoolExecutor(max_workers=args.jobs) as executor:
futures = [executor.submit(run_group, group, args.timeout) for group in groups]
try:
for future in as_completed(futures):
result = future.result()
total_tests += result.tests_run
status = "OK" if result.returncode == 0 else "FAILED"
print(
f"Group {result.group.index}/{len(groups)}: {status}, "
f"{result.tests_run} tests in {result.duration:.1f}s",
flush=True,
)
if result.returncode != 0:
failed.append(result)
if result.returncode != 0 or args.verbose:
print(result.output, flush=True)
except KeyboardInterrupt:
interrupted.set()
executor.shutdown(wait=False, cancel_futures=True)
for pgid in list(running_groups):
signal_group(pgid, signal.SIGINT)
raise
print(f"\nRan {total_tests} tests in {time.monotonic() - start:.1f}s")
if failed:
print(f"{len(failed)} of {len(groups)} groups FAILED, rerun one with:")
for result in sorted(failed, key=lambda r: r.group.index):
print(
f" Group {result.group.index}: python3 -u -m unittest "
f"{' '.join(result.group.modules)}"
)
return 1
print("OK")
return 0
if __name__ == "__main__":
sys.exit(main())
+10 -2
View File
@@ -1,4 +1,12 @@
"""Constants for testing.""" """Constants for testing."""
TEST_DB = "test.db" import os
TEST_DB_CLEANUPS = ["test.db", "test.db-shm", "test.db-wal"] import tempfile
# Unique per process so test groups can run in parallel without sharing a database
TEST_DB = os.path.join(tempfile.gettempdir(), f"frigate_test_{os.getpid()}.db")
TEST_DB_CLEANUPS = [TEST_DB, f"{TEST_DB}-shm", f"{TEST_DB}-wal"]
# The database is thrown away after each test, so skip syncing every commit to
# disk, which is slow when several test processes are writing at once
TEST_DB_PRAGMAS = {"synchronous": "off"}
+3 -3
View File
@@ -18,7 +18,7 @@ from frigate.debug_replay import DebugReplayManager
from frigate.jobs.export import JobStatePublisher from frigate.jobs.export import JobStatePublisher
from frigate.models import Event, Recordings, ReviewSegment from frigate.models import Event, Recordings, ReviewSegment
from frigate.review.types import SeverityEnum from frigate.review.types import SeverityEnum
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS
class AuthTestClient(TestClient): class AuthTestClient(TestClient):
@@ -38,12 +38,12 @@ class AuthTestClient(TestClient):
class BaseTestHttp(unittest.TestCase): class BaseTestHttp(unittest.TestCase):
def setUp(self, models): def setUp(self, models):
# setup clean database for each test run # setup clean database for each test run
migrate_db = SqliteDatabase("test.db") migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
del logging.getLogger("peewee_migrate").handlers[:] del logging.getLogger("peewee_migrate").handlers[:]
router = Router(migrate_db) router = Router(migrate_db)
router.run() router.run()
migrate_db.close() migrate_db.close()
self.db = SqliteQueueDatabase(TEST_DB) self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
self.db.bind(models) self.db.bind(models)
# The export job manager broadcasts via JobStatePublisher on # The export job manager broadcasts via JobStatePublisher on
+14 -12
View File
@@ -1,11 +1,12 @@
import os import os
import shutil import shutil
from unittest.mock import MagicMock import tempfile
from unittest.mock import MagicMock, patch
import cv2 import cv2
import numpy as np import numpy as np
from frigate.output.preview import PREVIEW_CACHE_DIR, PREVIEW_FRAME_TYPE from frigate.output.preview import PREVIEW_FRAME_TYPE
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@@ -15,14 +16,15 @@ class TestHttpLatestFrame(BaseTestHttp):
self.app = super().create_app() self.app = super().create_app()
self.app.detected_frames_processor = MagicMock() self.app.detected_frames_processor = MagicMock()
if os.path.exists(PREVIEW_CACHE_DIR): # a directory of its own so test groups running in parallel can't
shutil.rmtree(PREVIEW_CACHE_DIR) # delete each other's preview frames
os.makedirs(PREVIEW_CACHE_DIR) self.preview_dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.preview_dir, ignore_errors=True)
def tearDown(self): cache_dir_patch = patch(
if os.path.exists(PREVIEW_CACHE_DIR): "frigate.output.preview.PREVIEW_CACHE_DIR", self.preview_dir
shutil.rmtree(PREVIEW_CACHE_DIR) )
super().tearDown() cache_dir_patch.start()
self.addCleanup(cache_dir_patch.stop)
def test_latest_frame_fallback_to_preview(self): def test_latest_frame_fallback_to_preview(self):
camera = "front_door" camera = "front_door"
@@ -45,7 +47,7 @@ class TestHttpLatestFrame(BaseTestHttp):
2, 2,
) )
preview_path = os.path.join( preview_path = os.path.join(
PREVIEW_CACHE_DIR, f"preview_{camera}-1234567890.0.{PREVIEW_FRAME_TYPE}" self.preview_dir, f"preview_{camera}-1234567890.0.{PREVIEW_FRAME_TYPE}"
) )
cv2.imwrite(preview_path, dummy_frame) cv2.imwrite(preview_path, dummy_frame)
@@ -80,7 +82,7 @@ class TestHttpLatestFrame(BaseTestHttp):
# 2. Create a dummy preview file # 2. Create a dummy preview file
preview_path = os.path.join( preview_path = os.path.join(
PREVIEW_CACHE_DIR, f"preview_{camera}-999.0.{PREVIEW_FRAME_TYPE}" self.preview_dir, f"preview_{camera}-999.0.{PREVIEW_FRAME_TYPE}"
) )
cv2.imwrite(preview_path, dummy_frame) cv2.imwrite(preview_path, dummy_frame)
+7 -3
View File
@@ -617,9 +617,13 @@ class TestExportTimelineAlignment(unittest.TestCase):
): ):
windows = exporter._staged_chapter_windows() windows = exporter._staged_chapter_windows()
path = exporter._build_recording_segment_chapter_metadata_file(windows) # the real cache dir only exists once the container has started
self.addCleanup(os.remove, path) with tempfile.TemporaryDirectory() as tmpdir:
content = Path(path).read_text() chapter_path = os.path.join(tmpdir, "chapters.txt")
exporter._chapter_metadata_path = lambda: chapter_path # type: ignore[method-assign]
path = exporter._build_recording_segment_chapter_metadata_file(windows)
self.assertEqual(path, chapter_path)
content = Path(chapter_path).read_text()
self.assertIn("START=0\nEND=21500", content) self.assertIn("START=0\nEND=21500", content)
self.assertIn("START=21500\nEND=43000", content) self.assertIn("START=21500\nEND=43000", content)
+3 -3
View File
@@ -21,7 +21,7 @@ from frigate.api.media_auth import (
) )
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.models import Event, Export, Recordings, ReviewSegment from frigate.models import Event, Export, Recordings, ReviewSegment
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS
_CONFIG = { _CONFIG = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
@@ -222,11 +222,11 @@ class TestExportResolution(unittest.TestCase):
"""Export resolution requires a DB lookup.""" """Export resolution requires a DB lookup."""
def setUp(self): def setUp(self):
migrate_db = SqliteDatabase("test.db") migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
del logging.getLogger("peewee_migrate").handlers[:] del logging.getLogger("peewee_migrate").handlers[:]
Router(migrate_db).run() Router(migrate_db).run()
migrate_db.close() migrate_db.close()
self.db = SqliteQueueDatabase(TEST_DB) self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
self.db.bind([Event, ReviewSegment, Recordings, Export]) self.db.bind([Event, ReviewSegment, Recordings, Export])
self.config = FrigateConfig(**_CONFIG) self.config = FrigateConfig(**_CONFIG)
+3 -3
View File
@@ -12,7 +12,7 @@ from playhouse.sqliteq import SqliteQueueDatabase
from frigate.models import Notice, NoticeStats from frigate.models import Notice, NoticeStats
from frigate.notices.registry import NoticeRegistry from frigate.notices.registry import NoticeRegistry
from frigate.notices.types import NOTICE_KINDS, NoticeKind, NoticeSeverity, notice_id from frigate.notices.types import NOTICE_KINDS, NoticeKind, NoticeSeverity, notice_id
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS
# kinds that switch on the lifecycle knobs, so the tests do not depend on the # kinds that switch on the lifecycle knobs, so the tests do not depend on the
# values the real catalog picks # values the real catalog picks
@@ -49,12 +49,12 @@ class TestNoticeKinds(unittest.TestCase):
class RegistryTestCase(unittest.TestCase): class RegistryTestCase(unittest.TestCase):
def setUp(self): def setUp(self):
migrate_db = SqliteDatabase("test.db") migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
del logging.getLogger("peewee_migrate").handlers[:] del logging.getLogger("peewee_migrate").handlers[:]
router = Router(migrate_db) router = Router(migrate_db)
router.run() router.run()
migrate_db.close() migrate_db.close()
self.db = SqliteQueueDatabase(TEST_DB) self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
self.db.bind([Notice, NoticeStats]) self.db.bind([Notice, NoticeStats])
self.registry = NoticeRegistry() self.registry = NoticeRegistry()
self.listener = MagicMock() self.listener = MagicMock()
+20 -20
View File
@@ -1,23 +1,23 @@
import os import os
import shutil import shutil
import tempfile
import unittest import unittest
from unittest.mock import patch
from frigate.output.preview import ( from frigate.output.preview import PREVIEW_FRAME_TYPE, get_most_recent_preview_frame
PREVIEW_CACHE_DIR,
PREVIEW_FRAME_TYPE,
get_most_recent_preview_frame,
)
class TestPreviewLoader(unittest.TestCase): class TestPreviewLoader(unittest.TestCase):
def setUp(self): def setUp(self):
if os.path.exists(PREVIEW_CACHE_DIR): # a directory of its own so test groups running in parallel can't
shutil.rmtree(PREVIEW_CACHE_DIR) # delete each other's preview frames
os.makedirs(PREVIEW_CACHE_DIR) self.preview_dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.preview_dir, ignore_errors=True)
def tearDown(self): cache_dir_patch = patch(
if os.path.exists(PREVIEW_CACHE_DIR): "frigate.output.preview.PREVIEW_CACHE_DIR", self.preview_dir
shutil.rmtree(PREVIEW_CACHE_DIR) )
cache_dir_patch.start()
self.addCleanup(cache_dir_patch.stop)
def test_get_most_recent_preview_frame_missing(self): def test_get_most_recent_preview_frame_missing(self):
self.assertIsNone(get_most_recent_preview_frame("test_camera")) self.assertIsNone(get_most_recent_preview_frame("test_camera"))
@@ -28,14 +28,14 @@ class TestPreviewLoader(unittest.TestCase):
for ts in ["1000.0", "2000.0", "1500.0"]: for ts in ["1000.0", "2000.0", "1500.0"]:
with open( with open(
os.path.join( os.path.join(
PREVIEW_CACHE_DIR, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}" self.preview_dir, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}"
), ),
"w", "w",
) as f: ) as f:
f.write(f"test_{ts}") f.write(f"test_{ts}")
expected_path = os.path.join( expected_path = os.path.join(
PREVIEW_CACHE_DIR, f"preview_{camera}-2000.0.{PREVIEW_FRAME_TYPE}" self.preview_dir, f"preview_{camera}-2000.0.{PREVIEW_FRAME_TYPE}"
) )
self.assertEqual(get_most_recent_preview_frame(camera), expected_path) self.assertEqual(get_most_recent_preview_frame(camera), expected_path)
@@ -45,7 +45,7 @@ class TestPreviewLoader(unittest.TestCase):
for ts in ["1000.0", "2000.0"]: for ts in ["1000.0", "2000.0"]:
with open( with open(
os.path.join( os.path.join(
PREVIEW_CACHE_DIR, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}" self.preview_dir, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}"
), ),
"w", "w",
) as f: ) as f:
@@ -53,7 +53,7 @@ class TestPreviewLoader(unittest.TestCase):
# Test finding frame before or at 1500 # Test finding frame before or at 1500
expected_path = os.path.join( expected_path = os.path.join(
PREVIEW_CACHE_DIR, f"preview_{camera}-1000.0.{PREVIEW_FRAME_TYPE}" self.preview_dir, f"preview_{camera}-1000.0.{PREVIEW_FRAME_TYPE}"
) )
self.assertEqual( self.assertEqual(
get_most_recent_preview_frame(camera, before=1500.0), expected_path get_most_recent_preview_frame(camera, before=1500.0), expected_path
@@ -67,7 +67,7 @@ class TestPreviewLoader(unittest.TestCase):
other_camera = "other_camera" other_camera = "other_camera"
with open( with open(
os.path.join( os.path.join(
PREVIEW_CACHE_DIR, f"preview_{other_camera}-3000.0.{PREVIEW_FRAME_TYPE}" self.preview_dir, f"preview_{other_camera}-3000.0.{PREVIEW_FRAME_TYPE}"
), ),
"w", "w",
) as f: ) as f:
@@ -78,12 +78,12 @@ class TestPreviewLoader(unittest.TestCase):
def test_get_most_recent_preview_frame_hyphenated_camera(self): def test_get_most_recent_preview_frame_hyphenated_camera(self):
for name in ("preview_front-2000.0", "preview_front-door-3000.0"): for name in ("preview_front-2000.0", "preview_front-door-3000.0"):
with open( with open(
os.path.join(PREVIEW_CACHE_DIR, f"{name}.{PREVIEW_FRAME_TYPE}"), "w" os.path.join(self.preview_dir, f"{name}.{PREVIEW_FRAME_TYPE}"), "w"
) as f: ) as f:
f.write("test") f.write("test")
expected_path = os.path.join( expected_path = os.path.join(
PREVIEW_CACHE_DIR, f"preview_front-2000.0.{PREVIEW_FRAME_TYPE}" self.preview_dir, f"preview_front-2000.0.{PREVIEW_FRAME_TYPE}"
) )
self.assertEqual(get_most_recent_preview_frame("front"), expected_path) self.assertEqual(get_most_recent_preview_frame("front"), expected_path)
self.assertEqual( self.assertEqual(
@@ -91,5 +91,5 @@ class TestPreviewLoader(unittest.TestCase):
) )
def test_get_most_recent_preview_frame_no_directory(self): def test_get_most_recent_preview_frame_no_directory(self):
shutil.rmtree(PREVIEW_CACHE_DIR) shutil.rmtree(self.preview_dir)
self.assertIsNone(get_most_recent_preview_frame("test_camera")) self.assertIsNone(get_most_recent_preview_frame("test_camera"))
+9 -3
View File
@@ -13,22 +13,28 @@ from frigate.config import FrigateConfig
from frigate.const import STREAM_TYPE_MAIN, STREAM_TYPE_SUB from frigate.const import STREAM_TYPE_MAIN, STREAM_TYPE_SUB
from frigate.models import Event, Recordings from frigate.models import Event, Recordings
from frigate.storage import MAX_CALCULATED_BANDWIDTH, StorageMaintainer from frigate.storage import MAX_CALCULATED_BANDWIDTH, StorageMaintainer
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS
class TestHttp(unittest.TestCase): class TestHttp(unittest.TestCase):
def setUp(self): def setUp(self):
# setup clean database for each test run # setup clean database for each test run
migrate_db = SqliteDatabase("test.db") migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
del logging.getLogger("peewee_migrate").handlers[:] del logging.getLogger("peewee_migrate").handlers[:]
router = Router(migrate_db) router = Router(migrate_db)
router.run() router.run()
migrate_db.close() migrate_db.close()
self.db = SqliteQueueDatabase(TEST_DB) self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
models = [Event, Recordings] models = [Event, Recordings]
self.db.bind(models) self.db.bind(models)
self.test_dir = tempfile.mkdtemp() self.test_dir = tempfile.mkdtemp()
# The maintainers are never stopped, so a real subscriber leaks a zmq
# context whose garbage collection can hang a later test in term()
subscriber_patch = patch("frigate.storage.CameraConfigUpdateSubscriber")
subscriber_patch.start()
self.addCleanup(subscriber_patch.stop)
self.minimal_config = { self.minimal_config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"cameras": { "cameras": {