Qt-free view_model builds monitor view state and isolates the settings draft from the running config snapshot; gui.py/app.py are a thin PyQt5 shell that only renders already-decided FrameAnalysis. 39 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
149 lines
5.5 KiB
Python
149 lines
5.5 KiB
Python
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from v1.fall_state import FallState
|
|
from v1.video_source import SourceStatus
|
|
from v1.view_model import (
|
|
DraftValidationError,
|
|
SettingsDraft,
|
|
StatusColor,
|
|
build_monitor_view,
|
|
config_version,
|
|
)
|
|
|
|
|
|
def _keypoint(x, y, confidence=0.9):
|
|
return SimpleNamespace(x=float(x), y=float(y), confidence=float(confidence))
|
|
|
|
|
|
def _person(track_id, state, keypoints=None, box=(10.0, 10.0, 40.0, 120.0)):
|
|
points = keypoints or [_keypoint(index, index) for index in range(17)]
|
|
pose = SimpleNamespace(box_xyxy=box, box_confidence=0.9, keypoints=tuple(points))
|
|
tracked = SimpleNamespace(track_id=track_id, detected_at_monotonic=1.0, pose=pose)
|
|
return SimpleNamespace(tracked_pose=tracked, pose_evidence=None, state=state)
|
|
|
|
|
|
def _analysis(status=SourceStatus.CONNECTED, people=(), events=(), has_image=True):
|
|
image = object() if has_image else None
|
|
packet = SimpleNamespace(image=image, timestamp_monotonic=1.0, status=status, error=None)
|
|
return SimpleNamespace(packet=packet, people=tuple(people), events=tuple(events))
|
|
|
|
|
|
def test_state_maps_to_semantic_color_and_red_is_confirmed_only():
|
|
view = build_monitor_view(
|
|
_analysis(
|
|
people=[
|
|
_person("P-0001", FallState.NORMAL),
|
|
_person("P-0002", FallState.SUSPECT),
|
|
_person("P-0003", FallState.CONFIRMED),
|
|
]
|
|
)
|
|
)
|
|
colors = {overlay.track_id: overlay.color for overlay in view.people}
|
|
assert colors["P-0001"] is StatusColor.SUCCESS
|
|
assert colors["P-0002"] is StatusColor.CAUTION
|
|
assert colors["P-0003"] is StatusColor.CRITICAL
|
|
critical = [o for o in view.people if o.color is StatusColor.CRITICAL]
|
|
assert [o.state for o in critical] == [FallState.CONFIRMED]
|
|
assert view.highest_state is FallState.CONFIRMED
|
|
|
|
|
|
def test_disconnected_frame_shows_reconnect_text_and_no_fall():
|
|
view = build_monitor_view(_analysis(status=SourceStatus.RETRYING, people=(), has_image=False))
|
|
assert view.connected is False
|
|
assert view.status_text == "正在重连…"
|
|
assert view.status_color is StatusColor.OFFLINE
|
|
assert view.people == ()
|
|
assert view.highest_state is FallState.NORMAL
|
|
|
|
|
|
def test_skeleton_overlay_drops_low_confidence_keypoints_and_labels_person():
|
|
points = [_keypoint(index, index, 0.9) for index in range(17)]
|
|
points[9] = _keypoint(9, 9, 0.1) # left wrist below threshold
|
|
view = build_monitor_view(
|
|
_analysis(people=[_person("P-0007", FallState.SUSPECT, keypoints=points)]),
|
|
keypoint_min_confidence=0.4,
|
|
)
|
|
overlay = view.people[0]
|
|
assert overlay.keypoints[9] is None
|
|
assert overlay.keypoints[7] is not None
|
|
# Edges touching keypoint 9 (5-7-9 arm) must not draw the 7->9 segment.
|
|
assert all(
|
|
not (a is overlay.keypoints[7] and b is None) for a, b in overlay.skeleton_segments
|
|
)
|
|
assert overlay.label == "P-0007 · SUSPECT"
|
|
assert overlay.box_xyxy == (10.0, 10.0, 40.0, 120.0)
|
|
|
|
|
|
def test_confirmed_event_becomes_a_badge():
|
|
event = SimpleNamespace(event_id="FALL-000001", track_id="P-0003", latency_seconds=1.8)
|
|
view = build_monitor_view(_analysis(events=[event]))
|
|
assert len(view.events) == 1
|
|
assert view.events[0].event_id == "FALL-000001"
|
|
assert view.events[0].latency_seconds == 1.8
|
|
|
|
|
|
def _initial():
|
|
return {
|
|
"keypoint_confidence_threshold": 0.4,
|
|
"suspect_window_seconds": 0.5,
|
|
"confirm_window_seconds": 1.8,
|
|
"recovery_window_seconds": 2.0,
|
|
"cooldown_seconds": 10.0,
|
|
"model_confidence_threshold": 0.25,
|
|
}
|
|
|
|
|
|
def test_editing_draft_does_not_change_running_or_saved_config():
|
|
draft = SettingsDraft(_initial())
|
|
draft.edit("confirm_window_seconds", 2.5)
|
|
assert draft.is_dirty is True
|
|
assert draft.draft_values["confirm_window_seconds"] == 2.5
|
|
assert draft.saved_values["confirm_window_seconds"] == 1.8
|
|
assert draft.running_values["confirm_window_seconds"] == 1.8
|
|
assert draft.has_pending_for_next_start is False
|
|
|
|
|
|
def test_save_marks_pending_but_running_only_changes_on_start():
|
|
draft = SettingsDraft(_initial())
|
|
before_version = draft.running_version
|
|
draft.edit("confirm_window_seconds", 2.5)
|
|
message = draft.save()
|
|
assert "下次开始监控" in message
|
|
assert draft.is_dirty is False
|
|
assert draft.has_pending_for_next_start is True
|
|
assert draft.running_values["confirm_window_seconds"] == 1.8
|
|
assert draft.running_version == before_version
|
|
|
|
new_version = draft.start_monitoring()
|
|
assert draft.running_values["confirm_window_seconds"] == 2.5
|
|
assert draft.has_pending_for_next_start is False
|
|
assert new_version != before_version
|
|
|
|
|
|
def test_discard_reverts_draft_to_saved():
|
|
draft = SettingsDraft(_initial())
|
|
draft.edit("cooldown_seconds", 20.0)
|
|
draft.discard()
|
|
assert draft.is_dirty is False
|
|
assert draft.draft_values["cooldown_seconds"] == 10.0
|
|
|
|
|
|
def test_out_of_range_draft_value_is_rejected():
|
|
draft = SettingsDraft(_initial())
|
|
with pytest.raises(DraftValidationError):
|
|
draft.edit("confirm_window_seconds", 5.0)
|
|
with pytest.raises(DraftValidationError):
|
|
draft.edit("keypoint_confidence_threshold", 1.5)
|
|
with pytest.raises(DraftValidationError):
|
|
draft.edit("unknown_field", 1.0)
|
|
|
|
|
|
def test_config_version_is_stable_and_sensitive_to_values():
|
|
values = _initial()
|
|
assert config_version(values) == config_version(dict(values))
|
|
changed = dict(values)
|
|
changed["cooldown_seconds"] = 12.0
|
|
assert config_version(values) != config_version(changed)
|