Files
ilaandClaude Opus 4.8 d3cd8ecb55 feat(v1): edit detection sensitivity from the settings tab
Adds write_local_event_tuning and a sensitivity group (confirm window,
horizontal angle, require-rapid-drop, require-lower-body) that persists to
the untracked local config and applies next start. 79 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 00:00:51 +08:00

282 lines
8.9 KiB
Python

import json
import pytest
from v1.config import ConfigError, build_ffmpeg_options, load_config
def _write_config(path, source):
path.write_text(
json.dumps(
{
"source": source,
"model": {
"path": "models/best.pt",
"sha256": "a" * 64,
"confidence_threshold": 0.25,
},
"event": {
"keypoint_confidence_threshold": 0.4,
"suspect_window_seconds": 0.5,
"confirm_window_seconds": 1.8,
"recovery_window_seconds": 2.0,
"cooldown_seconds": 10.0,
},
"artifacts": {"event_dir": "artifacts/events"},
},
ensure_ascii=False,
),
encoding="utf-8",
)
def test_load_config_resolves_source_from_environment_variable(tmp_path, monkeypatch):
config_file = tmp_path / "config.json"
_write_config(
config_file,
{"id": "lobby-camera-01", "rtsp_url_env": "SILVER_POSE_RTSP_URL"},
)
monkeypatch.setenv("SILVER_POSE_RTSP_URL", "rtsp://demo.invalid/live")
config = load_config(config_file)
assert config.source_id == "lobby-camera-01"
assert config.source_url == "rtsp://demo.invalid/live"
assert config.event.confirm_window_seconds == 1.8
def test_load_config_rejects_embedded_source_address(tmp_path):
config_file = tmp_path / "config.json"
_write_config(
config_file,
{"id": "lobby-camera-01", "url": "rtsp://demo.invalid/live"},
)
with pytest.raises(ConfigError, match="rtsp_url_env"):
load_config(config_file)
def test_structured_source_builds_percent_encoded_rtsp_url(tmp_path):
config_file = tmp_path / "config.json"
_write_config(
config_file,
{
"id": "hik-ipcamera-101",
"host": "192.0.2.10",
"port": 554,
"channel": "102",
"username": "admin",
"password": "p@ss/w:d",
},
)
config = load_config(config_file)
assert config.source_url == "rtsp://admin:p%40ss%2Fw%3Ad@192.0.2.10:554/Streaming/Channels/102"
assert config.source_id == "hik-ipcamera-101"
assert config.source_mode == "stream"
def test_structured_source_requires_password(tmp_path):
config_file = tmp_path / "config.json"
_write_config(
config_file,
{"id": "hik-ipcamera-101", "host": "192.0.2.10", "username": "admin"},
)
with pytest.raises(ConfigError, match="password"):
load_config(config_file)
def test_structured_credentials_are_excluded_from_config_version(tmp_path):
first_file = tmp_path / "a.json"
second_file = tmp_path / "b.json"
_write_config(
first_file,
{"id": "hik-101", "host": "192.0.2.10", "username": "admin", "password": "secret-a"},
)
_write_config(
second_file,
{"id": "hik-101", "host": "192.0.2.20", "username": "operator", "password": "secret-b"},
)
first = load_config(first_file)
second = load_config(second_file)
assert first.source_url != second.source_url
assert first.runtime_config_version == second.runtime_config_version
def test_write_local_camera_source_round_trips_through_load_config(tmp_path):
from v1.config import write_local_camera_source
config_file = tmp_path / "config.local.json"
_write_config(config_file, {"id": "seed", "rtsp_url_env": "SILVER_POSE_RTSP_URL"})
write_local_camera_source(
config_file,
source_id="hik",
host="192.0.2.10",
port=554,
channel="102",
username="admin",
password="p@ss/w:d",
)
config = load_config(config_file)
assert config.source_url == "rtsp://admin:p%40ss%2Fw%3Ad@192.0.2.10:554/Streaming/Channels/102"
assert config.source_mode == "stream"
assert config.source_id == "hik"
def test_write_local_event_tuning_round_trips_and_preserves_other_fields(tmp_path):
from v1.config import write_local_event_tuning
config_file = tmp_path / "config.local.json"
_write_config(
config_file,
{"id": "seed", "host": "192.0.2.10", "username": "u", "password": "p"},
)
write_local_event_tuning(
config_file,
require_rapid_drop=True,
require_lower_body=True,
horizontal_angle_threshold_degrees=35.0,
confirm_window_seconds=2.5,
)
config = load_config(config_file)
assert config.event.require_rapid_drop is True
assert config.event.require_lower_body is True
assert config.event.horizontal_angle_threshold_degrees == 35.0
assert config.event.confirm_window_seconds == 2.5
# unrelated event fields are preserved from the seed
assert config.event.cooldown_seconds == 10.0
def test_public_example_config_has_no_embedded_credentials():
from pathlib import Path
example = json.loads(Path("v1/config.example.json").read_text(encoding="utf-8"))
source = example["source"]
for forbidden in ("host", "username", "password", "url", "rtsp_url"):
assert forbidden not in source
assert "rtsp_url_env" in source
def test_sensitivity_fields_default_to_lenient(tmp_path):
config_file = tmp_path / "config.json"
_write_config(
config_file,
{"id": "c", "host": "192.0.2.10", "username": "u", "password": "p"},
)
config = load_config(config_file)
assert config.event.require_rapid_drop is False
assert config.event.require_lower_body is False
assert config.event.horizontal_angle_threshold_degrees == 45.0
def test_build_ffmpeg_options_variants():
assert build_ffmpeg_options() == "rtsp_transport;tcp|stimeout;5000000|fflags;nobuffer|flags;low_delay"
assert build_ffmpeg_options("udp", 3, False) == "rtsp_transport;udp|stimeout;3000000"
with pytest.raises(ValueError):
build_ffmpeg_options("http")
def test_source_capture_tuning_defaults_to_tcp_low_latency(tmp_path):
config_file = tmp_path / "config.json"
_write_config(
config_file,
{"id": "c", "host": "192.0.2.10", "username": "u", "password": "p"},
)
config = load_config(config_file)
assert config.transport == "tcp"
assert config.timeout_seconds == 5.0
assert config.low_latency is True
assert (
config.ffmpeg_capture_options
== "rtsp_transport;tcp|stimeout;5000000|fflags;nobuffer|flags;low_delay"
)
def test_source_capture_tuning_is_parsed(tmp_path):
config_file = tmp_path / "config.json"
_write_config(
config_file,
{
"id": "c", "host": "192.0.2.10", "username": "u", "password": "p",
"transport": "udp", "timeout_seconds": 3, "low_latency": False,
},
)
config = load_config(config_file)
assert config.transport == "udp"
assert config.ffmpeg_capture_options == "rtsp_transport;udp|stimeout;3000000"
def test_invalid_transport_is_rejected(tmp_path):
config_file = tmp_path / "config.json"
_write_config(
config_file,
{"id": "c", "host": "192.0.2.10", "username": "u", "password": "p", "transport": "http"},
)
with pytest.raises(ConfigError, match="transport"):
load_config(config_file)
def test_source_mode_defaults_to_stream(tmp_path, monkeypatch):
config_file = tmp_path / "config.json"
_write_config(
config_file,
{"id": "lobby-camera-01", "rtsp_url_env": "SILVER_POSE_RTSP_URL"},
)
monkeypatch.setenv("SILVER_POSE_RTSP_URL", "rtsp://demo.invalid/live")
assert load_config(config_file).source_mode == "stream"
def test_source_mode_replay_is_parsed(tmp_path, monkeypatch):
config_file = tmp_path / "config.json"
_write_config(
config_file,
{"id": "lobby-camera-01", "rtsp_url_env": "SILVER_POSE_RTSP_URL", "mode": "replay"},
)
monkeypatch.setenv("SILVER_POSE_RTSP_URL", "rtsp://demo.invalid/live")
assert load_config(config_file).source_mode == "replay"
def test_invalid_source_mode_is_rejected(tmp_path, monkeypatch):
config_file = tmp_path / "config.json"
_write_config(
config_file,
{"id": "lobby-camera-01", "rtsp_url_env": "SILVER_POSE_RTSP_URL", "mode": "loop"},
)
monkeypatch.setenv("SILVER_POSE_RTSP_URL", "rtsp://demo.invalid/live")
with pytest.raises(ConfigError, match="mode"):
load_config(config_file)
def test_runtime_config_version_is_stable_and_excludes_rtsp_address(tmp_path, monkeypatch):
config_file = tmp_path / "config.json"
_write_config(
config_file,
{"id": "lobby-camera-01", "rtsp_url_env": "SILVER_POSE_RTSP_URL"},
)
monkeypatch.setenv("SILVER_POSE_RTSP_URL", "rtsp://operator:secret@camera-a/live")
first = load_config(config_file)
monkeypatch.setenv("SILVER_POSE_RTSP_URL", "rtsp://operator:other-secret@camera-b/live")
second = load_config(config_file)
assert first.runtime_config_version == second.runtime_config_version
assert first.runtime_config_version.startswith("cfg-")