feat: initialize managed portal
This commit is contained in:
55
managed/store_dwell_alert/tests/test_bundle_layout.py
Normal file
55
managed/store_dwell_alert/tests/test_bundle_layout.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_offline_bundle_files_exist():
|
||||
assert (PROJECT_ROOT / "requirements.lock.txt").exists()
|
||||
assert (PROJECT_ROOT / "deploy" / "store-dwell-alert.service.tpl").exists()
|
||||
assert (PROJECT_ROOT / "scripts" / "install.sh").exists()
|
||||
assert (PROJECT_ROOT / "scripts" / "run.sh").exists()
|
||||
assert (PROJECT_ROOT / "scripts" / "install_service.sh").exists()
|
||||
assert (PROJECT_ROOT / "scripts" / "package_bundle.sh").exists()
|
||||
|
||||
|
||||
def test_run_script_contains_placeholder_rtsp_and_local_config():
|
||||
content = (PROJECT_ROOT / "scripts" / "run.sh").read_text(encoding="utf-8")
|
||||
assert 'RTSP_URL="${RTSP_URL:-rtsp://user:password@camera-ip:554/h264/ch1/main/av_stream}"' in content
|
||||
assert 'CONFIG_PATH="${PROJECT_DIR}/config/local.yaml"' in content
|
||||
assert "Please edit scripts/run.sh and set RTSP_URL before starting." in content
|
||||
|
||||
|
||||
def test_service_template_uses_portable_tokens():
|
||||
content = (PROJECT_ROOT / "deploy" / "store-dwell-alert.service.tpl").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert "__PROJECT_DIR__" in content
|
||||
assert "__CONFIG_PATH__" in content
|
||||
assert "/home/xiaozheng/store_dwell_alert" not in content
|
||||
|
||||
|
||||
def test_locked_requirements_pin_runtime_versions():
|
||||
content = (PROJECT_ROOT / "requirements.lock.txt").read_text(encoding="utf-8")
|
||||
assert "torch==2.6.0+cu124" in content
|
||||
assert "torchvision==0.21.0+cu124" in content
|
||||
assert "ultralytics==8.4.37" in content
|
||||
assert "opencv-python-headless==4.13.0.92" in content
|
||||
assert "PyYAML==6.0.3" in content
|
||||
assert "requests==2.33.1" in content
|
||||
|
||||
|
||||
def test_packaging_script_stages_expected_bundle_inputs():
|
||||
content = (PROJECT_ROOT / "scripts" / "package_bundle.sh").read_text(encoding="utf-8")
|
||||
assert 'BUNDLE_NAME="${BUNDLE_NAME:-store_dwell_alert_bundle}"' in content
|
||||
assert 'WHEELHOUSE_SOURCE="${WHEELHOUSE_SOURCE:-${PROJECT_DIR}/wheelhouse}"' in content
|
||||
assert 'WEIGHTS_SOURCE="${WEIGHTS_SOURCE:-${PROJECT_DIR}/weights/yolo11n.pt}"' in content
|
||||
assert 'cp "${PROJECT_DIR}/config/config.example.yaml" "${STAGE_DIR}/config/config.example.yaml"' in content
|
||||
assert 'cp -R "${WHEELHOUSE_SOURCE}" "${STAGE_DIR}/wheelhouse"' in content
|
||||
assert "config/108.local.yaml" not in content
|
||||
|
||||
|
||||
def test_asset_directories_are_tracked():
|
||||
assert (PROJECT_ROOT / "data" / "runtime" / ".gitkeep").exists()
|
||||
assert (PROJECT_ROOT / "data" / "staff_gallery" / ".gitkeep").exists()
|
||||
assert (PROJECT_ROOT / "weights" / ".gitkeep").exists()
|
||||
32
managed/store_dwell_alert/tests/test_config.py
Normal file
32
managed/store_dwell_alert/tests/test_config.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import load_config
|
||||
|
||||
|
||||
def test_load_config_reads_thresholds(tmp_path: Path):
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text(
|
||||
"camera_id: store_cam_01\n"
|
||||
"thresholds:\n"
|
||||
" min_people: 5\n"
|
||||
" min_dwell_seconds: 600\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
data = load_config(cfg)
|
||||
|
||||
assert data.camera_id == "store_cam_01"
|
||||
assert data.thresholds.min_people == 5
|
||||
assert data.thresholds.min_dwell_seconds == 600
|
||||
|
||||
|
||||
def test_load_config_uses_defaults_for_optional_sections(tmp_path: Path):
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("camera_id: store_cam_01\n", encoding="utf-8")
|
||||
|
||||
data = load_config(cfg)
|
||||
|
||||
assert data.stream.sample_fps == 2.0
|
||||
assert data.staff.min_hits == 3
|
||||
assert data.event_sink.path == "logs/events.jsonl"
|
||||
assert data.webhook.timeout_seconds == 5.0
|
||||
64
managed/store_dwell_alert/tests/test_detector_tracker.py
Normal file
64
managed/store_dwell_alert/tests/test_detector_tracker.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from app.modules.detector_tracker import (
|
||||
attach_track_signatures,
|
||||
extract_tracked_people,
|
||||
filter_person_detections,
|
||||
)
|
||||
|
||||
|
||||
def test_filter_person_detections_keeps_only_person_class():
|
||||
detections = [
|
||||
{"class_name": "person", "confidence": 0.8},
|
||||
{"class_name": "chair", "confidence": 0.9},
|
||||
]
|
||||
|
||||
result = filter_person_detections(detections)
|
||||
|
||||
assert result == [{"class_name": "person", "confidence": 0.8}]
|
||||
|
||||
|
||||
def test_extract_tracked_people_keeps_track_metadata():
|
||||
class FakeBox:
|
||||
def __init__(self, cls_idx, confidence, track_id, xyxy):
|
||||
self.cls = [cls_idx]
|
||||
self.conf = [confidence]
|
||||
self.id = [track_id]
|
||||
self.xyxy = [xyxy]
|
||||
|
||||
class FakeResult:
|
||||
names = {0: "person", 56: "chair"}
|
||||
|
||||
def __init__(self):
|
||||
self.boxes = [
|
||||
FakeBox(0, 0.87, 11, [1, 2, 3, 4]),
|
||||
FakeBox(56, 0.99, 12, [9, 9, 9, 9]),
|
||||
]
|
||||
|
||||
tracked_people = extract_tracked_people([FakeResult()])
|
||||
|
||||
assert tracked_people == [
|
||||
{
|
||||
"track_id": 11,
|
||||
"class_name": "person",
|
||||
"confidence": 0.87,
|
||||
"xyxy": [1.0, 2.0, 3.0, 4.0],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_attach_track_signatures_adds_color_signature():
|
||||
frame = [
|
||||
[[10, 20, 30], [10, 20, 30]],
|
||||
[[10, 20, 30], [10, 20, 30]],
|
||||
]
|
||||
tracked_people = [
|
||||
{
|
||||
"track_id": 1,
|
||||
"class_name": "person",
|
||||
"confidence": 0.9,
|
||||
"xyxy": [0, 0, 2, 2],
|
||||
}
|
||||
]
|
||||
|
||||
enriched = attach_track_signatures(frame, tracked_people)
|
||||
|
||||
assert enriched[0]["signature"] == [0.0392, 0.0784, 0.1176]
|
||||
63
managed/store_dwell_alert/tests/test_dwell_engine.py
Normal file
63
managed/store_dwell_alert/tests/test_dwell_engine.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.modules.dwell_engine import DwellEngine, DwellSession, long_stay_count
|
||||
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def test_session_pauses_without_adding_absence_time():
|
||||
start = datetime(2026, 4, 15, 11, 0, tzinfo=TZ)
|
||||
session = DwellSession(person_id="cust_1", session_id="cust_1-s1", entered_at=start)
|
||||
session.mark_seen(start.replace(minute=2))
|
||||
session.pause(start.replace(minute=2, second=10))
|
||||
session.close_if_expired(start.replace(minute=7, second=11), pause_timeout_seconds=300)
|
||||
assert session.state == "closed"
|
||||
assert session.dwell_seconds() == 130
|
||||
|
||||
|
||||
def test_engine_emits_alert_when_five_long_stays_are_active():
|
||||
engine = DwellEngine(
|
||||
camera_id="store_cam_01",
|
||||
min_people=5,
|
||||
min_dwell_seconds=600,
|
||||
pause_timeout_seconds=300,
|
||||
alert_cooldown_seconds=600,
|
||||
)
|
||||
now = datetime(2026, 4, 15, 11, 20, tzinfo=TZ)
|
||||
observations = [{"person_id": f"cust_{idx}", "role": "customer"} for idx in range(5)]
|
||||
|
||||
engine.process_observations(observations, now.replace(minute=9, second=0))
|
||||
events = engine.process_observations(observations, now)
|
||||
|
||||
assert [event["event"] for event in events] == ["long_stay_alert"]
|
||||
assert events[0]["active_long_stay_count"] == 5
|
||||
|
||||
|
||||
def test_engine_emits_half_hour_report_with_closed_customers():
|
||||
engine = DwellEngine(
|
||||
camera_id="store_cam_01",
|
||||
min_people=5,
|
||||
min_dwell_seconds=600,
|
||||
pause_timeout_seconds=300,
|
||||
alert_cooldown_seconds=600,
|
||||
)
|
||||
seen_at = datetime(2026, 4, 15, 11, 10, tzinfo=TZ)
|
||||
engine.process_observations([{"person_id": "cust_1", "role": "customer"}], seen_at)
|
||||
engine.process_observations([], datetime(2026, 4, 15, 11, 12, tzinfo=TZ))
|
||||
engine.process_observations([], datetime(2026, 4, 15, 11, 18, tzinfo=TZ))
|
||||
|
||||
events = engine.process_observations([], datetime(2026, 4, 15, 11, 30, tzinfo=TZ))
|
||||
|
||||
report = next(event for event in events if event["event"] == "half_hour_report")
|
||||
assert report["window_end"] == "2026-04-15T11:30:00+08:00"
|
||||
assert report["closed_customers"][0]["person_id"] == "cust_1"
|
||||
|
||||
|
||||
def test_long_stay_count_excludes_staff():
|
||||
sessions = [
|
||||
{"role": "customer", "state": "active", "dwell_seconds": 700},
|
||||
{"role": "staff", "state": "active", "dwell_seconds": 40000},
|
||||
]
|
||||
assert long_stay_count(sessions, min_dwell_seconds=600) == 1
|
||||
37
managed/store_dwell_alert/tests/test_identity_resolver.py
Normal file
37
managed/store_dwell_alert/tests/test_identity_resolver.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.modules.identity_resolver import IdentityResolver, choose_reentry_match
|
||||
|
||||
|
||||
def test_choose_reentry_match_prefers_recent_high_similarity():
|
||||
paused_people = [
|
||||
{"person_id": "cust_1", "paused_at": 100, "similarity": 0.91},
|
||||
{"person_id": "cust_2", "paused_at": 80, "similarity": 0.87},
|
||||
]
|
||||
|
||||
result = choose_reentry_match(
|
||||
paused_people=paused_people,
|
||||
now_ts=250,
|
||||
pause_timeout_seconds=300,
|
||||
min_similarity=0.90,
|
||||
)
|
||||
|
||||
assert result == "cust_1"
|
||||
|
||||
|
||||
def test_identity_resolver_reuses_person_after_short_pause():
|
||||
resolver = IdentityResolver(pause_timeout_seconds=300, reentry_similarity_threshold=0.95)
|
||||
now = datetime(2026, 4, 15, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
|
||||
|
||||
first = resolver.resolve(
|
||||
[{"track_id": 1, "signature": [0.1, 0.2, 0.3], "role": "customer"}],
|
||||
now,
|
||||
)
|
||||
resolver.resolve([], now.replace(minute=1))
|
||||
second = resolver.resolve(
|
||||
[{"track_id": 2, "signature": [0.1, 0.2, 0.3], "role": "customer"}],
|
||||
now.replace(minute=2),
|
||||
)
|
||||
|
||||
assert first[0]["person_id"] == second[0]["person_id"]
|
||||
116
managed/store_dwell_alert/tests/test_main_smoke.py
Normal file
116
managed/store_dwell_alert/tests/test_main_smoke.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from app.main import build_app, process_frame, process_observations, run_forever
|
||||
|
||||
|
||||
def test_build_app_returns_named_components():
|
||||
config_path = Path(__file__).resolve().parent.parent / "config" / "config.example.yaml"
|
||||
app = build_app(config_path)
|
||||
assert "stream_reader" in app
|
||||
assert "dwell_engine" in app
|
||||
assert "notifier" in app
|
||||
assert "staff_matcher" in app
|
||||
|
||||
|
||||
def test_process_observations_writes_event_file(tmp_path):
|
||||
config = tmp_path / "config.yaml"
|
||||
config.write_text(
|
||||
"camera_id: store_cam_01\n"
|
||||
"thresholds:\n"
|
||||
" min_people: 1\n"
|
||||
" min_dwell_seconds: 1\n"
|
||||
" pause_timeout_seconds: 300\n"
|
||||
" alert_cooldown_seconds: 600\n"
|
||||
"event_sink:\n"
|
||||
f" path: {tmp_path / 'events.jsonl'}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
app = build_app(config)
|
||||
|
||||
events = process_observations(
|
||||
app,
|
||||
[{"person_id": "cust_1", "role": "customer"}],
|
||||
datetime.fromisoformat("2026-04-15T11:00:02+08:00"),
|
||||
)
|
||||
events = process_observations(
|
||||
app,
|
||||
[{"person_id": "cust_1", "role": "customer"}],
|
||||
datetime.fromisoformat("2026-04-15T11:00:05+08:00"),
|
||||
)
|
||||
|
||||
assert events[0]["event"] == "long_stay_alert"
|
||||
|
||||
|
||||
def test_run_forever_stops_at_max_frames(tmp_path):
|
||||
config = tmp_path / "config.yaml"
|
||||
config.write_text(
|
||||
"camera_id: store_cam_01\n"
|
||||
"stream:\n"
|
||||
" rtsp_url: rtsp://example\n"
|
||||
" sample_fps: 100\n"
|
||||
" reconnect_backoff_seconds: 0.01\n"
|
||||
"event_sink:\n"
|
||||
f" path: {tmp_path / 'events.jsonl'}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
app = build_app(config)
|
||||
|
||||
class FakeReader:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
self.closed = False
|
||||
|
||||
def read(self):
|
||||
self.calls += 1
|
||||
return [[0]]
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
class FakeTracker:
|
||||
def track(self, _frame):
|
||||
return [{"track_id": 1, "signature": [0.1, 0.2, 0.3], "role": "customer"}]
|
||||
|
||||
app["stream_reader"] = FakeReader()
|
||||
app["tracker"] = FakeTracker()
|
||||
|
||||
processed = run_forever(app, max_frames=2)
|
||||
|
||||
assert processed == 2
|
||||
assert app["stream_reader"].closed is True
|
||||
assert app["event_sink_path"].exists() is True
|
||||
|
||||
|
||||
def test_process_frame_uses_tracker_and_identity_resolver(tmp_path):
|
||||
config = tmp_path / "config.yaml"
|
||||
config.write_text(
|
||||
"camera_id: store_cam_01\n"
|
||||
"thresholds:\n"
|
||||
" min_people: 1\n"
|
||||
" min_dwell_seconds: 1\n"
|
||||
" pause_timeout_seconds: 300\n"
|
||||
" alert_cooldown_seconds: 600\n"
|
||||
"event_sink:\n"
|
||||
f" path: {tmp_path / 'events.jsonl'}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
app = build_app(config)
|
||||
|
||||
class FakeTracker:
|
||||
def track(self, _frame):
|
||||
return [{"track_id": 7, "signature": [0.1, 0.2, 0.3], "role": "customer"}]
|
||||
|
||||
app["tracker"] = FakeTracker()
|
||||
process_frame(
|
||||
app,
|
||||
frame=[[0]],
|
||||
when=datetime.fromisoformat("2026-04-15T11:00:03+08:00"),
|
||||
)
|
||||
events = process_frame(
|
||||
app,
|
||||
frame=[[0]],
|
||||
when=datetime.fromisoformat("2026-04-15T11:00:06+08:00"),
|
||||
)
|
||||
|
||||
assert events[0]["event"] == "long_stay_alert"
|
||||
178
managed/store_dwell_alert/tests/test_manage_api.py
Normal file
178
managed/store_dwell_alert/tests/test_manage_api.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from app.manage_api import create_app
|
||||
|
||||
|
||||
def build_client(project_root: Path):
|
||||
config_path = project_root / "config" / "local.yaml"
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(
|
||||
"camera_id: store_cam_01\n"
|
||||
"timezone: Asia/Shanghai\n"
|
||||
"stream:\n"
|
||||
" rtsp_url: rtsp://before-update\n"
|
||||
" sample_fps: 2.0\n"
|
||||
" reconnect_backoff_seconds: 5.0\n"
|
||||
"event_sink:\n"
|
||||
" path: logs/events.jsonl\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
logs_dir = project_root / "logs"
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
(logs_dir / "events.jsonl").write_text(
|
||||
"\n".join(
|
||||
[
|
||||
json.dumps(
|
||||
{
|
||||
"event": "long_stay_alert",
|
||||
"camera_id": "store_cam_01",
|
||||
"ts": "2026-04-16T09:00:00+08:00",
|
||||
}
|
||||
),
|
||||
json.dumps(
|
||||
{
|
||||
"event": "half_hour_report",
|
||||
"camera_id": "store_cam_01",
|
||||
"window_start": "2026-04-16T09:00:00+08:00",
|
||||
"window_end": "2026-04-16T09:30:00+08:00",
|
||||
"active_customer_count": 2,
|
||||
"active_customers": [
|
||||
{"person_id": "cust_1", "dwell_seconds": 600},
|
||||
{"person_id": "cust_2", "dwell_seconds": 780},
|
||||
],
|
||||
"closed_customers": [
|
||||
{"person_id": "cust_3", "final_dwell_seconds": 450}
|
||||
],
|
||||
"staff_seen_count": 1,
|
||||
}
|
||||
),
|
||||
json.dumps(
|
||||
{
|
||||
"event": "half_hour_report",
|
||||
"camera_id": "store_cam_01",
|
||||
"window_start": "2026-04-16T09:30:00+08:00",
|
||||
"window_end": "2026-04-16T10:00:00+08:00",
|
||||
"active_customer_count": 1,
|
||||
"active_customers": [
|
||||
{"person_id": "cust_4", "dwell_seconds": 900}
|
||||
],
|
||||
"closed_customers": [
|
||||
{"person_id": "cust_5", "final_dwell_seconds": 300},
|
||||
{"person_id": "cust_6", "final_dwell_seconds": 120},
|
||||
],
|
||||
"staff_seen_count": 0,
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(logs_dir / "runtime.log").write_text("runtime ok\n", encoding="utf-8")
|
||||
|
||||
app = create_app(config_path)
|
||||
app.testing = True
|
||||
return app.test_client(), config_path
|
||||
|
||||
|
||||
def test_get_manage_health(tmp_path: Path):
|
||||
client, _ = build_client(tmp_path)
|
||||
|
||||
response = client.get("/api/manage/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["status"] == "ok"
|
||||
assert response.json["project_type"] == "store_dwell_alert"
|
||||
assert response.json["runtime_status"] == "running"
|
||||
|
||||
|
||||
def test_get_manage_config(tmp_path: Path):
|
||||
client, config_path = build_client(tmp_path)
|
||||
|
||||
response = client.get("/api/manage/config")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["camera_id"] == "store_cam_01"
|
||||
assert response.json["stream"]["rtsp_url"] == "rtsp://before-update"
|
||||
assert response.json["config_path"] == str(config_path)
|
||||
|
||||
|
||||
def test_put_manage_config_updates_rtsp_url(tmp_path: Path):
|
||||
client, config_path = build_client(tmp_path)
|
||||
|
||||
response = client.put(
|
||||
"/api/manage/config",
|
||||
json={"rtsp_url": "rtsp://after-update"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["stream"]["rtsp_url"] == "rtsp://after-update"
|
||||
|
||||
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
assert saved["stream"]["rtsp_url"] == "rtsp://after-update"
|
||||
|
||||
|
||||
def test_get_manage_summary(tmp_path: Path):
|
||||
client, _ = build_client(tmp_path)
|
||||
|
||||
response = client.get("/api/manage/summary")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["result_type"] == "store_dwell_alert"
|
||||
assert response.json["last_result_time"] == "2026-04-16T10:00:00+08:00"
|
||||
assert response.json["metrics"]["alert_count"] == 1
|
||||
assert response.json["metrics"]["active_customer_count"] == 1
|
||||
assert response.json["metrics"]["longest_dwell_seconds"] == 900
|
||||
assert response.json["metrics"]["recent_window_stats"][0]["window_end"] == "2026-04-16T10:00:00+08:00"
|
||||
|
||||
|
||||
def test_get_manage_windows(tmp_path: Path):
|
||||
client, _ = build_client(tmp_path)
|
||||
|
||||
response = client.get("/api/manage/windows?page=1&page_size=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["total"] == 2
|
||||
assert response.json["page"] == 1
|
||||
assert response.json["page_size"] == 1
|
||||
assert len(response.json["items"]) == 1
|
||||
assert response.json["items"][0]["window_end"] == "2026-04-16T10:00:00+08:00"
|
||||
assert response.json["items"][0]["active_wait_seconds"] == [900]
|
||||
|
||||
|
||||
def test_get_manage_files(tmp_path: Path):
|
||||
client, _ = build_client(tmp_path)
|
||||
|
||||
response = client.get("/api/manage/files")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert {item["path"] for item in response.json["files"]} == {
|
||||
"logs/events.jsonl",
|
||||
"logs/runtime.log",
|
||||
}
|
||||
|
||||
|
||||
def test_get_manage_files_preview(tmp_path: Path):
|
||||
client, _ = build_client(tmp_path)
|
||||
|
||||
response = client.get("/api/manage/files/preview?path=logs/events.jsonl&lines=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["path"] == "logs/events.jsonl"
|
||||
assert response.json["count"] == 2
|
||||
assert "2026-04-16T10:00:00+08:00" in response.json["lines"][-1]
|
||||
|
||||
|
||||
def test_get_manage_files_download(tmp_path: Path):
|
||||
client, _ = build_client(tmp_path)
|
||||
|
||||
response = client.get("/api/manage/files/download?path=logs/runtime.log")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data == b"runtime ok\n"
|
||||
15
managed/store_dwell_alert/tests/test_notifier.py
Normal file
15
managed/store_dwell_alert/tests/test_notifier.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import json
|
||||
|
||||
from app.modules.notifier import append_json_event
|
||||
|
||||
|
||||
def test_append_json_event_writes_jsonl(tmp_path):
|
||||
output = tmp_path / "logs" / "events.jsonl"
|
||||
|
||||
append_json_event(output, {"event": "long_stay_alert", "count": 5})
|
||||
append_json_event(output, {"event": "half_hour_report", "count": 3})
|
||||
|
||||
lines = output.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
assert json.loads(lines[0]) == {"event": "long_stay_alert", "count": 5}
|
||||
assert json.loads(lines[1]) == {"event": "half_hour_report", "count": 3}
|
||||
15
managed/store_dwell_alert/tests/test_reporter.py
Normal file
15
managed/store_dwell_alert/tests/test_reporter.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.modules.reporter import floor_half_hour, should_emit_half_hour_report
|
||||
|
||||
|
||||
def test_half_hour_report_emits_on_half_hour_boundaries():
|
||||
assert should_emit_half_hour_report("2026-04-15T11:00:00+08:00") is True
|
||||
assert should_emit_half_hour_report("2026-04-15T11:30:00+08:00") is True
|
||||
assert should_emit_half_hour_report("2026-04-15T11:17:00+08:00") is False
|
||||
|
||||
|
||||
def test_floor_half_hour_rounds_down():
|
||||
dt = datetime(2026, 4, 15, 11, 47, 13, tzinfo=ZoneInfo("Asia/Shanghai"))
|
||||
assert floor_half_hour(dt).isoformat() == "2026-04-15T11:30:00+08:00"
|
||||
54
managed/store_dwell_alert/tests/test_staff_filter.py
Normal file
54
managed/store_dwell_alert/tests/test_staff_filter.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import json
|
||||
|
||||
from app.modules.staff_filter import StaffEmbedding, StaffMatcher, load_staff_gallery, staff_vote
|
||||
|
||||
|
||||
def test_staff_vote_requires_multiple_hits():
|
||||
assert staff_vote([True, False, True], min_hits=2) is True
|
||||
assert staff_vote([True, False, False], min_hits=2) is False
|
||||
|
||||
|
||||
def test_load_staff_gallery_reads_json_signatures(tmp_path):
|
||||
gallery_dir = tmp_path / "staff_gallery"
|
||||
gallery_dir.mkdir()
|
||||
(gallery_dir / "alice.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"staff_id": "alice",
|
||||
"signature": [0.2, 0.3, 0.4],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
embeddings = load_staff_gallery(gallery_dir)
|
||||
|
||||
assert len(embeddings) == 1
|
||||
assert embeddings[0].staff_id == "alice"
|
||||
assert embeddings[0].signature == [0.2, 0.3, 0.4]
|
||||
|
||||
|
||||
def test_staff_matcher_promotes_person_to_staff_after_enough_hits():
|
||||
matcher = StaffMatcher(
|
||||
gallery=[
|
||||
StaffEmbedding(
|
||||
staff_id="staff_1",
|
||||
signature=[0.1, 0.2, 0.3],
|
||||
source="inline",
|
||||
)
|
||||
],
|
||||
similarity_threshold=0.95,
|
||||
min_hits=2,
|
||||
vote_window=3,
|
||||
)
|
||||
|
||||
first = matcher.classify(
|
||||
[{"person_id": "cust_1", "signature": [0.1, 0.2, 0.3], "role": "customer"}]
|
||||
)
|
||||
second = matcher.classify(
|
||||
[{"person_id": "cust_1", "signature": [0.1, 0.2, 0.3], "role": "customer"}]
|
||||
)
|
||||
|
||||
assert first[0]["role"] == "customer"
|
||||
assert second[0]["role"] == "staff"
|
||||
assert second[0]["staff_id"] == "staff_1"
|
||||
37
managed/store_dwell_alert/tests/test_stream_reader.py
Normal file
37
managed/store_dwell_alert/tests/test_stream_reader.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from app.modules.stream_reader import StreamHealth
|
||||
from app.modules.stream_reader import RTSPFrameReader
|
||||
|
||||
|
||||
def test_stream_health_marks_disconnect_after_failures():
|
||||
health = StreamHealth(max_failures=3)
|
||||
health.record_failure()
|
||||
health.record_failure()
|
||||
health.record_failure()
|
||||
assert health.is_disconnected is True
|
||||
|
||||
|
||||
def test_stream_health_reset_clears_disconnect():
|
||||
health = StreamHealth(max_failures=2, failures=2)
|
||||
health.reset()
|
||||
assert health.is_disconnected is False
|
||||
|
||||
|
||||
def test_rtsp_frame_reader_uses_capture_factory():
|
||||
class FakeCapture:
|
||||
def __init__(self, _url):
|
||||
self.frames = [(True, "frame-1")]
|
||||
|
||||
def read(self):
|
||||
return self.frames.pop(0)
|
||||
|
||||
def release(self):
|
||||
return None
|
||||
|
||||
reader = RTSPFrameReader(
|
||||
rtsp_url="rtsp://example",
|
||||
sample_fps=2.0,
|
||||
reconnect_backoff_seconds=5.0,
|
||||
capture_factory=FakeCapture,
|
||||
)
|
||||
|
||||
assert reader.read() == "frame-1"
|
||||
Reference in New Issue
Block a user