Phase 11
This commit is contained in:
commit
c5bcb95d74
172 files changed
+9487
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Unit tests."""
|
||||
@@ -0,0 +1,4 @@
|
||||
experiment_id,session_id,track_id,voice_mode,prompt_condition,prompt_played,pseudo_prompt,prompt_time,prompt_elapsed_time_sec,response_window_started,response_detected,reaction_time_sec,response_turn_level,turn_confirmed,turn_level,max_yaw_delta_toward_signage,face_detected_ever,pose_estimated_ever,face_detection_rate,pose_estimation_rate,valid_for_turn_analysis,valid_for_voice_analysis,final_status,completed,excluded,exclusion_reason,track_duration_sec,trigger_crossed,trigger_crossing_direction,movement_direction,camera_position_note,notes
|
||||
exp,s1,1,prompt,prompt,True,False,t,1.0,True,True,0.8,weak,True,weak,25.0,True,True,0.8,0.7,True,True,completed,True,False,none,3.0,True,left_to_right,left_to_right,test,
|
||||
exp,s2,2,control,control,False,True,t,1.0,True,False,,none,False,none,5.0,True,True,0.7,0.6,True,True,completed,True,False,none,3.0,True,left_to_right,left_to_right,test,
|
||||
exp,s3,3,prompt,prompt,True,False,t,1.0,True,False,,,False,not_evaluable,,False,False,0.0,0.0,False,False,not_evaluable,True,False,no_pose_estimated,3.0,True,left_to_right,left_to_right,test,
|
||||
|
@@ -0,0 +1,5 @@
|
||||
from main import build_analysis_parser
|
||||
|
||||
def test_analysis_parser() -> None:
|
||||
args=build_analysis_parser().parse_args(["--input","in","--output","out","--no-analysis-plots","--analysis-response-test","fisher"])
|
||||
assert str(args.input)=="in" and args.no_analysis_plots and args.analysis_response_test=="fisher"
|
||||
@@ -0,0 +1,13 @@
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
pd=pytest.importorskip("pandas")
|
||||
from src.analysis_loader import AnalysisLoader
|
||||
from src.config_loader import AnalysisSettings
|
||||
from src.exceptions import ConfigurationError
|
||||
|
||||
CFG=AnalysisSettings(True,"in","out",True,True,True,True,"weak","auto","mannwhitney","valid_turn_analysis",False,True,.05)
|
||||
def test_load_find_convert_validate(tmp_path:Path)->None:
|
||||
row={"experiment_id":"e","session_id":"s","track_id":"1","prompt_condition":"prompt","response_detected":"True","valid_for_voice_analysis":"False","valid_for_turn_analysis":"False","turn_confirmed":"False","turn_level":"not_evaluable","reaction_time_sec":"","max_yaw_delta_toward_signage":"","excluded":"False","exclusion_reason":"none"}
|
||||
for i in range(2):pd.DataFrame([row]).to_csv(tmp_path/f"{i}_person_analysis_table.csv",index=False,encoding="utf-8-sig")
|
||||
l=AnalysisLoader(CFG);paths=l.find_person_analysis_tables(tmp_path);df=l.load_person_analysis_tables(paths);assert len(df)==2 and bool(df.response_detected.iloc[0]) and not bool(df.valid_for_voice_analysis.iloc[0])
|
||||
with pytest.raises(ConfigurationError):l.validate_person_analysis_columns(pd.DataFrame({"x":[]}))
|
||||
@@ -0,0 +1,6 @@
|
||||
from src.models import AnalysisConditionSummary,AnalysisRunSummary
|
||||
|
||||
def test_analysis_models() -> None:
|
||||
condition=AnalysisConditionSummary("prompt",1,1,1,0,1.,0,0,None,None,None,None,None,None,0,1,0,0,1,0)
|
||||
run=AnalysisRunSummary("a",1,1,1,0,1,0,"t","out","note")
|
||||
assert condition.response_rate==1. and run.input_files_count==1
|
||||
@@ -0,0 +1,11 @@
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
pd=pytest.importorskip("pandas");pytest.importorskip("matplotlib")
|
||||
from src.analysis_plots import AnalysisPlots
|
||||
from src.config_loader import AnalysisSettings
|
||||
from src.statistical_analyzer import StatisticalAnalyzer
|
||||
|
||||
def test_five_plots(tmp_path:Path)->None:
|
||||
c=AnalysisSettings(True,"in",str(tmp_path),True,True,True,True,"weak","auto","mannwhitney","valid_turn_analysis",True,True,.05)
|
||||
df=pd.DataFrame([{"prompt_condition":"prompt","valid_for_voice_analysis":True,"response_detected":True,"reaction_time_sec":1.,"max_yaw_delta_toward_signage":20.,"turn_level":"weak","excluded":False,"valid_for_turn_analysis":True,"exclusion_reason":"none"},{"prompt_condition":"control","valid_for_voice_analysis":True,"response_detected":False,"reaction_time_sec":None,"max_yaw_delta_toward_signage":5.,"turn_level":"none","excluded":False,"valid_for_turn_analysis":True,"exclusion_reason":"none"}])
|
||||
assert len(AnalysisPlots(c).generate(df,StatisticalAnalyzer(c).run_analysis(df)))==5
|
||||
@@ -0,0 +1,12 @@
|
||||
from pathlib import Path
|
||||
from src.analysis_reporter import AnalysisReporter
|
||||
from src.config_loader import AnalysisSettings
|
||||
from src.models import AnalysisResult,AnalysisRunSummary,ResponseRateTestResult
|
||||
|
||||
def cfg(path:Path,overwrite:bool=True)->AnalysisSettings:return AnalysisSettings(True,"in",str(path),True,True,True,True,"weak","auto","mannwhitney","valid_turn_analysis",False,overwrite,.05)
|
||||
def empty(path:Path)->AnalysisResult:return AnalysisResult([],ResponseRateTestResult("none",0,0,0,0,None,None,None,None,None,"none",None,None,"empty"),[],[],[],AnalysisRunSummary("a",0,0,0,0,0,0,"t",str(path),"日本語"))
|
||||
def test_six_bom_csvs_and_overwrite(tmp_path:Path)->None:
|
||||
paths=AnalysisReporter(cfg(tmp_path)).write(empty(tmp_path));assert len(paths)==6 and all(p.read_bytes().startswith(b"\xef\xbb\xbf") for p in paths)
|
||||
try:AnalysisReporter(cfg(tmp_path,False)).write(empty(tmp_path));assert False
|
||||
except FileExistsError:pass
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from src.analysis_table_builder import AnalysisTableBuilder
|
||||
from src.config_loader import UnifiedLogSettings
|
||||
from src.models import PersonStateSummary
|
||||
|
||||
CFG=UnifiedLogSettings(True,"x",True,True,True,True,30,True,True,True,True,False,False,"camera",True)
|
||||
|
||||
def test_prompt_control_disabled_records() -> None:
|
||||
b=AnalysisTableBuilder(CFG,"sid")
|
||||
for mode in ("prompt","control",None):
|
||||
s=PersonStateSummary("exp",1,voice_mode=mode,prompt_played=mode=="prompt",pseudo_prompt=mode=="control",
|
||||
response_detected=True,reaction_time_sec=.8,turn_confirmed=True,turn_level="weak",
|
||||
max_yaw_delta_toward_signage=25.,valid_for_turn_analysis=False,exclusion_reason="no_pose_estimated")
|
||||
r=b.build_record(s)
|
||||
assert r.prompt_condition==(mode or "disabled") and r.camera_position_note=="camera"
|
||||
assert r.response_detected and r.reaction_time_sec==.8 and r.valid_for_turn_analysis is False
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Source-level guard for a GUI callback regression that occurred before first frame."""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_runtime_callback_does_not_reference_screenshot_path() -> None:
|
||||
source = (Path(__file__).parents[1] / "src" / "application.py").read_text(encoding="utf-8")
|
||||
callback_body = source.split("def _emit_runtime_update", 1)[1]
|
||||
assert "Saved screenshot" not in callback_body
|
||||
screenshot_body = source.split("def _save_screenshot", 1)[1].split("def _emit_runtime_update", 1)[0]
|
||||
assert 'LOGGER.info("Saved screenshot: %s", path)' in screenshot_body
|
||||
@@ -0,0 +1,55 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
from src.audio_player import AudioPlayer
|
||||
from src.config_loader import AudioSettings
|
||||
|
||||
|
||||
def config(**values: object) -> AudioSettings:
|
||||
base = dict(enabled=True, backend="pygame", file_path="prompt.wav", volume=.7, overlap_policy="skip")
|
||||
base.update(values)
|
||||
return AudioSettings(**base)
|
||||
|
||||
|
||||
def test_disabled_and_missing_file_are_safe(tmp_path: Path) -> None:
|
||||
assert AudioPlayer(config(enabled=False)).play() is False
|
||||
player = AudioPlayer(config())
|
||||
player.load(tmp_path / "missing.wav")
|
||||
assert player.play() is False
|
||||
|
||||
|
||||
def test_mock_backend_load_play_volume_and_close(tmp_path: Path) -> None:
|
||||
path = tmp_path / "prompt.wav"
|
||||
path.write_bytes(b"test")
|
||||
channel = Mock()
|
||||
channel.get_busy.return_value = False
|
||||
sound = Mock()
|
||||
sound.play.return_value = channel
|
||||
mixer = Mock()
|
||||
mixer.get_init.return_value = True
|
||||
mixer.Sound.return_value = sound
|
||||
backend = Mock(mixer=mixer)
|
||||
player = AudioPlayer(config(), backend)
|
||||
player.load(path)
|
||||
assert player.play()
|
||||
sound.set_volume.assert_called_once_with(.7)
|
||||
player.close()
|
||||
player.close()
|
||||
|
||||
|
||||
def test_overlap_skip_and_restart(tmp_path: Path) -> None:
|
||||
path = tmp_path / "prompt.wav"
|
||||
path.write_bytes(b"x")
|
||||
for policy, expected in (("skip", 1), ("restart", 2)):
|
||||
channel = Mock()
|
||||
channel.get_busy.return_value = True
|
||||
sound = Mock(play=Mock(return_value=channel))
|
||||
mixer = Mock()
|
||||
mixer.get_init.return_value = True
|
||||
mixer.Sound.return_value = sound
|
||||
player = AudioPlayer(config(overlap_policy=policy), Mock(mixer=mixer))
|
||||
player.load(path)
|
||||
assert player.play()
|
||||
player.play()
|
||||
assert sound.play.call_count == expected
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from pathlib import Path
|
||||
from src.calibration_history import CalibrationHistory
|
||||
from src.models import CalibrationChangeRecord
|
||||
def test_calibration_roundtrip_bom(tmp_path:Path)->None:
|
||||
h=CalibrationHistory(tmp_path/"h.csv");r=CalibrationChangeRecord("id1","now","学生","camera_position","横","正面","改善");h.append(r);assert h.path.read_bytes().startswith(b"\xef\xbb\xbf");assert h.load()[0].after_value=="正面"
|
||||
def test_empty_history(tmp_path:Path)->None:assert CalibrationHistory(tmp_path/"none.csv").load()==[]
|
||||
@@ -0,0 +1,79 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from src.config_loader import load_config
|
||||
from src.exceptions import ConfigurationError
|
||||
|
||||
|
||||
DEFAULT = Path(__file__).parents[1] / "config" / "default.yaml"
|
||||
|
||||
|
||||
def write_yaml(tmp_path: Path, values: dict[str, object]) -> Path:
|
||||
path = tmp_path / "experiment.yaml"
|
||||
path.write_text(yaml.safe_dump(values), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_recursive_merge_and_experiment_precedence(tmp_path: Path) -> None:
|
||||
path = write_yaml(tmp_path, {"detector": {"confidence_threshold": 0.8}, "experiment": {"location": "lab"}})
|
||||
config = load_config(DEFAULT, path)
|
||||
assert config.detector.confidence_threshold == 0.8
|
||||
assert config.detector.iou_threshold == 0.5
|
||||
assert config.experiment.location == "lab"
|
||||
assert config.experiment.experiment_id == "phase1_default"
|
||||
|
||||
|
||||
def test_cli_override_is_last(tmp_path: Path) -> None:
|
||||
path = write_yaml(tmp_path, {"detector": {"confidence_threshold": 0.6}})
|
||||
config = load_config(DEFAULT, path, {"detector": {"confidence_threshold": 0.7}})
|
||||
assert config.detector.confidence_threshold == 0.7
|
||||
|
||||
|
||||
@pytest.mark.parametrize("override", [
|
||||
{"detector": {"confidence_threshold": 1.1}},
|
||||
{"evaluation_region": {"x1": -0.1}},
|
||||
{"evaluation_region": {"x1": 0.9, "x2": 0.8}},
|
||||
{"input": {"type": "video", "video_path": None}},
|
||||
{"tracker": {"match_thresh": 1.1}},
|
||||
{"crossing": {"minimum_track_points": 1}},
|
||||
{"performance": {"skip_frames": 1}},
|
||||
{"face": {"process_every_n_frames": 0}},
|
||||
{"face": {"roi_height_ratio": 1.1}},
|
||||
{"face": {"min_detection_confidence": -0.1}},
|
||||
{"head_pose": {"ema_alpha": 0.0}},
|
||||
{"head_pose": {"max_abs_angle": 0}},
|
||||
{"face_logging": {"flush_interval_frames": 0}},
|
||||
{"turn_detection": {"signage_yaw_direction": "sideways"}},
|
||||
{"turn_detection": {"baseline_strategy": "unknown"}},
|
||||
{"turn_detection": {"baseline_min_samples": 0}},
|
||||
{"turn_detection": {"subtle_threshold_deg": 25, "weak_threshold_deg": 20}},
|
||||
{"turn_detection": {"min_pose_estimation_rate_for_evaluation": 1.1}},
|
||||
{"turn_logging": {"flush_interval_frames": 0}},
|
||||
{"audio": {"backend": "unknown"}},
|
||||
{"audio": {"volume": 1.1}},
|
||||
{"audio": {"overlap_policy": "mix"}},
|
||||
{"voice_prompt": {"mode": "random"}},
|
||||
{"voice_prompt": {"trigger_strategy": "unknown"}},
|
||||
{"voice_prompt": {"response_window_sec": 0}},
|
||||
{"voice_prompt": {"fixed_position_axis": "z"}},
|
||||
{"voice_logging": {"flush_interval_frames": 0}},
|
||||
{"person_state": {"min_track_duration_sec": -1}},
|
||||
{"person_state_logging": {"flush_interval_frames": 0}},
|
||||
{"unified_logging": {"flush_interval_frames": 0}},
|
||||
{"analysis": {"alpha": 1.0}},
|
||||
{"analysis": {"response_rate_test": "bad"}},
|
||||
{"gui": {"preview_max_fps": 0}},
|
||||
{"gui": {"enabled": "yes"}},
|
||||
{"pilot_diagnostics": {"min_valid_voice_analysis_rate": 1.1}},
|
||||
{"pilot_diagnostics": {"max_condition_valid_count_ratio": 0.5}},
|
||||
])
|
||||
def test_invalid_config(tmp_path: Path, override: dict[str, object]) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
load_config(DEFAULT, write_yaml(tmp_path, override))
|
||||
|
||||
|
||||
def test_skip_frames_allowed_when_tracker_disabled(tmp_path: Path) -> None:
|
||||
path = write_yaml(tmp_path, {"tracker": {"enabled": False}, "performance": {"skip_frames": 2}})
|
||||
assert load_config(DEFAULT, path).performance.skip_frames == 2
|
||||
@@ -0,0 +1,40 @@
|
||||
from src.config_loader import CrossingSettings
|
||||
from src.crossing_detector import CrossingDetector
|
||||
from src.models import BoundingBox, PixelTriggerLine, TrackPoint
|
||||
|
||||
|
||||
def settings(**overrides: object) -> CrossingSettings:
|
||||
values = dict(enabled=True, count_once_per_track=True, deadband_pixels=3,
|
||||
minimum_displacement_pixels=10, minimum_track_points=3, require_confirmed_track=True)
|
||||
values.update(overrides)
|
||||
return CrossingSettings(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def points(values: list[float], vertical: bool = True) -> list[TrackPoint]:
|
||||
result = []
|
||||
for frame, value in enumerate(values, 1):
|
||||
x, y = (value, 20.0) if vertical else (20.0, value)
|
||||
result.append(TrackPoint(float(frame), str(frame), frame, x, y, x, y, BoundingBox(0, 0, 2, 2)))
|
||||
return result
|
||||
|
||||
|
||||
def test_vertical_directions_and_no_crossing() -> None:
|
||||
detector = CrossingDetector(settings(), PixelTriggerLine("vertical", 50))
|
||||
assert detector.detect(1, points([30, 45, 60]), True) == "left_to_right"
|
||||
assert detector.detect(2, points([70, 55, 40]), True) == "right_to_left"
|
||||
assert detector.detect(3, points([20, 30, 40]), True) is None
|
||||
|
||||
|
||||
def test_deadband_displacement_confirmation_and_once() -> None:
|
||||
detector = CrossingDetector(settings(), PixelTriggerLine("vertical", 50))
|
||||
assert detector.detect(1, points([46, 49, 54]), True) is None
|
||||
assert CrossingDetector(settings(minimum_displacement_pixels=30), PixelTriggerLine("vertical", 50)).detect(2, points([40, 50, 60]), True) is None
|
||||
assert detector.detect(4, points([30, 45, 60]), False) is None
|
||||
assert detector.detect(4, points([30, 45, 60]), True) == "left_to_right"
|
||||
assert detector.detect(4, points([60, 50, 30]), True) is None
|
||||
|
||||
|
||||
def test_horizontal_directions() -> None:
|
||||
detector = CrossingDetector(settings(), PixelTriggerLine("horizontal", 50))
|
||||
assert detector.detect(1, points([30, 45, 60], vertical=False), True) == "top_to_bottom"
|
||||
assert detector.detect(2, points([70, 55, 40], vertical=False), True) == "bottom_to_top"
|
||||
@@ -0,0 +1,69 @@
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
# The detector only needs NumPy for annotations; keep this unit test independent
|
||||
# from binary packages and real image/model execution.
|
||||
_inserted_numpy_stub = "numpy" not in sys.modules
|
||||
if _inserted_numpy_stub:
|
||||
sys.modules["numpy"] = SimpleNamespace(
|
||||
ndarray=object,
|
||||
isscalar=lambda value: isinstance(value, (int, float)),
|
||||
)
|
||||
|
||||
from src.config_loader import DetectorSettings
|
||||
from src.detector import PersonDetector
|
||||
|
||||
if _inserted_numpy_stub:
|
||||
sys.modules.pop("numpy", None)
|
||||
|
||||
|
||||
class FakeModel:
|
||||
def __init__(self) -> None:
|
||||
self.kwargs: dict[str, Any] = {}
|
||||
|
||||
def predict(self, **kwargs: Any) -> list[Any]:
|
||||
self.kwargs = kwargs
|
||||
return []
|
||||
|
||||
|
||||
class FakeFrame:
|
||||
shape = (10, 10, 3)
|
||||
|
||||
|
||||
def make_settings(half_precision: bool) -> DetectorSettings:
|
||||
return DetectorSettings(
|
||||
model_path="unused.pt",
|
||||
device="auto",
|
||||
confidence_threshold=0.4,
|
||||
iou_threshold=0.5,
|
||||
image_size=640,
|
||||
person_class_id=0,
|
||||
max_detections=30,
|
||||
half_precision=half_precision,
|
||||
use_agnostic_nms=False,
|
||||
)
|
||||
|
||||
|
||||
def run_detection(half_precision: bool, effective_half: bool) -> dict[str, Any]:
|
||||
detector = PersonDetector(make_settings(half_precision))
|
||||
model = FakeModel()
|
||||
detector._model = model
|
||||
detector._device_name = "cuda" if effective_half else "cpu"
|
||||
detector._half = effective_half
|
||||
detector.detect(FakeFrame()) # type: ignore[arg-type]
|
||||
return model.kwargs
|
||||
|
||||
|
||||
def test_cpu_does_not_pass_half_when_disabled() -> None:
|
||||
assert "half" not in run_detection(half_precision=False, effective_half=False)
|
||||
|
||||
|
||||
def test_cpu_does_not_pass_half_when_requested_but_unavailable() -> None:
|
||||
assert "half" not in run_detection(half_precision=True, effective_half=False)
|
||||
|
||||
|
||||
def test_cuda_passes_half_only_when_effectively_enabled() -> None:
|
||||
kwargs = run_detection(half_precision=True, effective_half=True)
|
||||
assert kwargs["half"] is True
|
||||
assert "quantize" not in kwargs
|
||||
@@ -0,0 +1,6 @@
|
||||
from pathlib import Path
|
||||
from src.experiment_notes import ExperimentNotes
|
||||
from src.models import ExperimentSessionNote
|
||||
def test_note_utf8_roundtrip(tmp_path:Path)->None:
|
||||
n=ExperimentSessionNote("n1",None,"e1","2026-08-09","prompt","学生","研究室","サイネージ正面",120.0,"水平","右側","左から右","昼",None,"こんにちは",.8,"config.yaml",None,None,10,None,"なし","日本語メモ")
|
||||
store=ExperimentNotes();path=store.save(n,tmp_path/"note.yaml");loaded=store.load(path);assert loaded.camera_position_note=="サイネージ正面" and loaded.condition=="prompt"
|
||||
@@ -0,0 +1,7 @@
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
from src.config_loader import load_config
|
||||
from src.experiment_protocol import ExperimentProtocolBuilder
|
||||
def test_protocol_exports(tmp_path:Path)->None:
|
||||
c=load_config("config/default.yaml",None);b=ExperimentProtocolBuilder(c.to_dict());p=b.build_default_protocol();assert p.voice_volume==c.audio.volume and p.camera_position_description==c.unified_logging.camera_position_note
|
||||
md=b.export_markdown(p,tmp_path/"p.md");yp=b.export_yaml(p,tmp_path/"p.yaml");assert "voice_volume" in md.read_text(encoding="utf-8");assert yaml.safe_load(yp.read_text(encoding="utf-8"))["privacy_note"]
|
||||
@@ -0,0 +1,36 @@
|
||||
from src.config_loader import FaceSettings
|
||||
from src.face_geometry import create_face_roi
|
||||
from src.models import BoundingBox, TrackedPerson
|
||||
|
||||
|
||||
def settings(**overrides: object) -> FaceSettings:
|
||||
values = dict(enabled=True, backend="mediapipe", process_every_n_frames=1, max_persons_per_frame=3,
|
||||
require_confirmed_track=True, process_only_inside_region=False, roi_height_ratio=.5,
|
||||
roi_expand_ratio=.1, min_roi_width=10, min_roi_height=10, static_image_mode=False,
|
||||
max_num_faces=1, refine_landmarks=True, min_detection_confidence=.5,
|
||||
min_tracking_confidence=.5)
|
||||
values.update(overrides)
|
||||
return FaceSettings(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def person(bbox: BoundingBox, confirmed: bool = True, inside: bool = True) -> TrackedPerson:
|
||||
return TrackedPerson(1, bbox, .9, 0, "person", bbox.center_x, bbox.center_y, bbox.foot_x, bbox.foot_y,
|
||||
inside, confirmed, 3, 0)
|
||||
|
||||
|
||||
def test_roi_ratio_expansion_and_clipping() -> None:
|
||||
roi = create_face_roi(person(BoundingBox(10, 10, 110, 210)), 200, 300, settings())
|
||||
assert roi is not None
|
||||
assert (roi.x1, roi.x2) == (0, 120)
|
||||
assert roi.y1 == 0
|
||||
assert roi.y2 == 120
|
||||
clipped = create_face_roi(person(BoundingBox(0, 0, 50, 100)), 60, 80, settings(roi_height_ratio=1.0))
|
||||
assert clipped and clipped.x1 == 0 and clipped.y1 == 0 and clipped.y2 == 80
|
||||
|
||||
|
||||
def test_small_unconfirmed_and_outside_are_excluded() -> None:
|
||||
assert create_face_roi(person(BoundingBox(0, 0, 20, 20)), 100, 100,
|
||||
settings(min_roi_width=40, min_roi_height=40)) is None
|
||||
assert create_face_roi(person(BoundingBox(0, 0, 80, 100), confirmed=False), 100, 100, settings()) is None
|
||||
assert create_face_roi(person(BoundingBox(0, 0, 80, 100), inside=False), 100, 100,
|
||||
settings(process_only_inside_region=True)) is None
|
||||
@@ -0,0 +1,32 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from src.config_loader import FaceLoggingSettings
|
||||
from src.face_logger import FaceLogger
|
||||
from src.models import FaceTrackState, HeadPoseResult
|
||||
|
||||
|
||||
def test_face_logs_success_failure_bom_and_summary_deduplication(tmp_path: Path) -> None:
|
||||
logger = FaceLogger(FaceLoggingSettings(True, True, True, 1, str(tmp_path)), "exp", "20260101_000000")
|
||||
logger.open()
|
||||
success = HeadPoseResult(1, 1, "now", .1, True, True, 1.0, -2.0, 3.0, 468, None, .9)
|
||||
failure = HeadPoseResult(1, 2, "later", .2, False, False, None, None, None, 0, None, None, "face_not_detected")
|
||||
logger.write_results([success, failure])
|
||||
state = FaceTrackState(1, total_frames=2, face_detected_frames=1, pose_estimated_frames=1,
|
||||
last_pitch=1.0, last_yaw=-2.0, last_roll=3.0, min_yaw=-2.0,
|
||||
max_yaw=-2.0, yaw_sum=-2.0, last_result=failure, finalized=True)
|
||||
logger.write_summary(state)
|
||||
logger.write_summary(state)
|
||||
logger.close()
|
||||
logger.close()
|
||||
paths = list(tmp_path.glob("*.csv"))
|
||||
assert len(paths) == 2
|
||||
assert all(path.read_bytes().startswith(b"\xef\xbb\xbf") for path in paths)
|
||||
frame_path = next(path for path in paths if "frames" in path.name)
|
||||
with frame_path.open(encoding="utf-8-sig", newline="") as stream:
|
||||
rows = list(csv.DictReader(stream))
|
||||
assert len(rows) == 2 and rows[1]["failure_reason"] == "face_not_detected"
|
||||
assert "yaw" in rows[0]
|
||||
summary_path = next(path for path in paths if "summary" in path.name)
|
||||
with summary_path.open(encoding="utf-8-sig", newline="") as stream:
|
||||
assert len(list(csv.DictReader(stream))) == 1
|
||||
@@ -0,0 +1,42 @@
|
||||
from collections import deque
|
||||
|
||||
import pytest
|
||||
|
||||
from src.face_processor import FaceTrackManager
|
||||
from src.models import BoundingBox, FaceROI, HeadPoseResult
|
||||
|
||||
|
||||
def result(track_id: int, yaw: float | None, detected: bool = True) -> HeadPoseResult:
|
||||
success = yaw is not None
|
||||
return HeadPoseResult(track_id, 1, "now", .1, detected, success,
|
||||
1.0 if success else None, yaw, 2.0 if success else None,
|
||||
6 if detected else 0, None, .9 if detected else None,
|
||||
"" if success else "face_not_detected")
|
||||
|
||||
|
||||
def test_face_roi_properties() -> None:
|
||||
bbox = BoundingBox(10, 20, 110, 220)
|
||||
roi = FaceROI(1, 5, 10, 115, 100, bbox, .8)
|
||||
assert (roi.width, roi.height) == (110, 90)
|
||||
assert (roi.center_x, roi.center_y) == (60.0, 55.0)
|
||||
|
||||
|
||||
def test_head_pose_result_success_and_failure_validation() -> None:
|
||||
assert result(1, -12.0).pose_estimated
|
||||
assert not result(1, None, False).face_detected
|
||||
with pytest.raises(ValueError):
|
||||
HeadPoseResult(1, 1, "now", 0, True, True, None, 1.0, 2.0, 6, None, .5)
|
||||
|
||||
|
||||
def test_face_track_statistics_and_history_limit() -> None:
|
||||
manager = FaceTrackManager(2)
|
||||
manager.update([result(1, -10.0), result(1, 20.0), result(1, None, False)])
|
||||
state = manager.get_state(1)
|
||||
assert state is not None
|
||||
assert state.total_frames == 3
|
||||
assert state.face_detection_rate == pytest.approx(2 / 3)
|
||||
assert state.pose_estimation_rate == pytest.approx(2 / 3)
|
||||
assert (state.min_yaw, state.max_yaw, state.mean_yaw) == (-10.0, 20.0, 5.0)
|
||||
assert len(state.history) == 2
|
||||
finalized = manager.finalize_track(1)
|
||||
assert finalized and finalized.finalized
|
||||
@@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
|
||||
from src.fps_counter import FPSCounter
|
||||
|
||||
|
||||
def test_initial_and_tick_and_average(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
times = iter([1.0, 1.5, 1.75])
|
||||
monkeypatch.setattr("src.fps_counter.time.perf_counter", lambda: next(times))
|
||||
counter = FPSCounter(2)
|
||||
assert counter.current_fps == counter.average_fps == 0.0
|
||||
assert counter.tick() == 0.0
|
||||
assert counter.tick() == pytest.approx(2.0)
|
||||
assert counter.tick() == pytest.approx(4.0)
|
||||
assert counter.average_fps == pytest.approx(3.0)
|
||||
|
||||
|
||||
def test_reset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
times = iter([1.0, 2.0])
|
||||
monkeypatch.setattr("src.fps_counter.time.perf_counter", lambda: next(times))
|
||||
counter = FPSCounter(3)
|
||||
counter.tick()
|
||||
counter.tick()
|
||||
counter.reset()
|
||||
assert counter.current_fps == counter.average_fps == 0.0
|
||||
assert counter.sample_count == 0
|
||||
|
||||
|
||||
def test_invalid_window() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
FPSCounter(0)
|
||||
@@ -0,0 +1,17 @@
|
||||
import time
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from src.gui.analysis_controller import AnalysisController
|
||||
from src.gui.gui_models import GuiAnalysisRequest
|
||||
def test_analysis_runner_and_callback(tmp_path:Path)->None:
|
||||
expected=[tmp_path/"condition_summary.csv"];finished=[]
|
||||
c=AnalysisController(lambda settings,i,o:expected);c.set_finished_callback(finished.extend);c.run_analysis(GuiAnalysisRequest(output_directory=str(tmp_path)));time.sleep(.05)
|
||||
assert finished==expected
|
||||
def test_analysis_double_run_guard()->None:
|
||||
def slow(s,i,o):time.sleep(.1);return []
|
||||
c=AnalysisController(slow);c.run_analysis(GuiAnalysisRequest())
|
||||
with pytest.raises(RuntimeError):c.run_analysis(GuiAnalysisRequest())
|
||||
def test_analysis_error_callback()->None:
|
||||
errors=[]
|
||||
def fail(s,i,o):raise RuntimeError("broken")
|
||||
c=AnalysisController(fail);c.set_error_callback(errors.append);c.run_analysis(GuiAnalysisRequest());time.sleep(.05);assert errors==["broken"]
|
||||
@@ -0,0 +1,16 @@
|
||||
from pathlib import Path
|
||||
from src.gui.config_panel import experiment_overrides,validate_experiment_request
|
||||
from src.gui.gui_models import GuiExperimentRequest
|
||||
CONFIG=str(Path(__file__).parents[1]/"config"/"experiment.yaml")
|
||||
def request(**values):return GuiExperimentRequest(config_path=CONFIG,**values)
|
||||
def test_invalid_numeric_fields()->None:
|
||||
assert not validate_experiment_request(request(camera_index=-1)).valid
|
||||
assert not validate_experiment_request(request(voice_volume=1.1)).valid
|
||||
assert not validate_experiment_request(request(face_every=0)).valid
|
||||
assert not validate_experiment_request(request(max_face_persons=0)).valid
|
||||
def test_video_requires_existing_path()->None:assert not validate_experiment_request(request(input_type="video")).valid
|
||||
def test_prompt_without_audio_is_warning()->None:
|
||||
result=validate_experiment_request(request(voice_mode="prompt"));assert result.valid and result.warnings
|
||||
def test_valid_request_and_overrides()->None:
|
||||
r=request(voice_mode="control",signage_yaw_direction="negative");assert validate_experiment_request(r).valid
|
||||
values=experiment_overrides(r);assert values["voice_prompt"]["mode"]=="control" and values["turn_detection"]["signage_yaw_direction"]=="negative"
|
||||
@@ -0,0 +1,21 @@
|
||||
import time
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from src.gui.experiment_controller import ExperimentController
|
||||
from src.gui.gui_models import GuiExperimentRequest
|
||||
CONFIG=str(Path(__file__).parents[1]/"config"/"experiment.yaml")
|
||||
class FakeApp:
|
||||
instances=[]
|
||||
def __init__(self,config,**kwargs):self.stop=False;self.paused=False;self.kwargs=kwargs;FakeApp.instances.append(self)
|
||||
def run(self):
|
||||
while not self.stop:time.sleep(.005)
|
||||
def request_stop(self):self.stop=True
|
||||
def request_pause(self):self.paused=True
|
||||
def request_resume(self):self.paused=False
|
||||
def test_controller_start_stop_pause_without_camera()->None:
|
||||
c=ExperimentController(FakeApp);c.start_experiment(GuiExperimentRequest(config_path=CONFIG));time.sleep(.02)
|
||||
assert c.is_running();c.pause_experiment();assert FakeApp.instances[-1].paused;c.resume_experiment();assert not FakeApp.instances[-1].paused;c.stop_experiment();time.sleep(.03);assert not c.is_running()
|
||||
def test_controller_prevents_double_start()->None:
|
||||
c=ExperimentController(FakeApp);c.start_experiment(GuiExperimentRequest(config_path=CONFIG));
|
||||
with pytest.raises(RuntimeError):c.start_experiment(GuiExperimentRequest(config_path=CONFIG))
|
||||
c.stop_experiment()
|
||||
@@ -0,0 +1,8 @@
|
||||
from src.gui.gui_models import GuiAnalysisRequest,GuiExperimentRequest
|
||||
def test_gui_request_defaults()->None:
|
||||
e=GuiExperimentRequest();a=GuiAnalysisRequest()
|
||||
assert e.input_type=="camera" and e.voice_mode=="disabled" and e.camera_position_note==""
|
||||
assert a.recursive and a.response_test=="auto"
|
||||
def test_gui_request_preserves_values()->None:
|
||||
e=GuiExperimentRequest(audio_file="x.wav",camera_position_note="front",voice_mode="prompt")
|
||||
assert e.audio_file=="x.wav" and e.camera_position_note=="front"
|
||||
@@ -0,0 +1,52 @@
|
||||
import math
|
||||
|
||||
from src.config_loader import HeadPoseSettings
|
||||
from src.head_pose import HeadPoseEstimator
|
||||
from src.models import FaceLandmark
|
||||
|
||||
|
||||
def settings(max_angle: float = 90.0) -> HeadPoseSettings:
|
||||
return HeadPoseSettings(True, "solvepnp", True, "ema", .4, max_angle)
|
||||
|
||||
|
||||
def landmarks() -> list[FaceLandmark]:
|
||||
return [FaceLandmark(index, float(index), float(index + 1), 0.0)
|
||||
for index in HeadPoseEstimator.LANDMARK_INDICES]
|
||||
|
||||
|
||||
def test_missing_landmarks_and_input_builders() -> None:
|
||||
estimator = HeadPoseEstimator(settings())
|
||||
assert estimator.estimate([], 640, 480).failure_reason == "insufficient_landmarks"
|
||||
assert len(estimator.build_image_points(landmarks()) or []) == 6
|
||||
matrix = estimator.build_camera_matrix(640, 480)
|
||||
assert matrix == [[640.0, 0.0, 320.0], [0.0, 640.0, 240.0], [0.0, 0.0, 1.0]]
|
||||
|
||||
|
||||
def test_euler_conversion_returns_floats() -> None:
|
||||
angles = HeadPoseEstimator.rotation_matrix_to_euler([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])
|
||||
assert all(isinstance(value, float) for value in angles)
|
||||
assert angles == (0.0, -0.0, 0.0)
|
||||
|
||||
|
||||
def test_angle_range_validation() -> None:
|
||||
estimator = HeadPoseEstimator(settings(45.0))
|
||||
assert estimator.result_from_angles((10.0, 20.0, 5.0)).success
|
||||
failed = estimator.result_from_angles((10.0, 60.0, 5.0))
|
||||
assert failed.failure_reason == "angle_out_of_range"
|
||||
assert (failed.pitch, failed.yaw, failed.roll) == (10.0, 60.0, 5.0)
|
||||
assert estimator.result_from_angles((math.nan, 0.0, 0.0)).failure_reason == "angle_out_of_range"
|
||||
|
||||
|
||||
def test_face_model_uses_image_coordinate_y_direction() -> None:
|
||||
_, chin, left_eye, right_eye, left_mouth, right_mouth = HeadPoseEstimator.MODEL_POINTS
|
||||
assert chin[1] > 0
|
||||
assert left_eye[1] < 0 and right_eye[1] < 0
|
||||
assert left_mouth[1] > 0 and right_mouth[1] > 0
|
||||
|
||||
|
||||
def test_roll_near_180_is_canonicalized_for_upright_face() -> None:
|
||||
estimator = HeadPoseEstimator(settings())
|
||||
assert estimator.normalize_angles((2.0, -5.0, -170.0)) == (2.0, -5.0, 10.0)
|
||||
assert estimator.normalize_angles((2.0, -5.0, 170.0)) == (2.0, -5.0, -10.0)
|
||||
result = estimator.result_from_angles((2.0, -5.0, -170.0))
|
||||
assert result.success and result.roll == 10.0
|
||||
@@ -0,0 +1,4 @@
|
||||
from src.improvement_recommender import ImprovementRecommender
|
||||
from src.models import ConditionBalanceDiagnostic
|
||||
def test_recommendation_categories()->None:
|
||||
b=ConditionBalanceDiagnostic(10,2,8,1,.5,.2,7,8.,False,"bad","add");cats={x.category for x in ImprovementRecommender().recommend(.3,.6,.7,b,8,1,True)};assert {"camera_position","face_detection","condition_balance","sample_size"}<=cats
|
||||
@@ -0,0 +1,8 @@
|
||||
from src.log_consistency_checker import LogConsistencyChecker
|
||||
from src.models import PersonAnalysisRecord
|
||||
|
||||
def test_detects_nonfatal_issues() -> None:
|
||||
r=PersonAnalysisRecord("e","s",1,"prompt","unknown",response_detected=True,reaction_time_sec=-1.,excluded=True,exclusion_reason="none",valid_for_voice_analysis=True)
|
||||
kinds={x.issue_type for x in LogConsistencyChecker().check([r,r])}
|
||||
assert {"missing_prompt_for_response","invalid_reaction_time","response_without_turn","duplicate_summary"}<=kinds
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import pytest
|
||||
|
||||
from src.models import BoundingBox, PixelRegion
|
||||
|
||||
|
||||
def test_bounding_box_properties() -> None:
|
||||
box = BoundingBox(10, 20, 30, 50)
|
||||
assert (box.width, box.height) == (20, 30)
|
||||
assert (box.center_x, box.center_y) == (20, 35)
|
||||
assert (box.foot_x, box.foot_y) == (20, 50)
|
||||
assert box.area == 600
|
||||
|
||||
|
||||
def test_pixel_region_contains_including_boundary() -> None:
|
||||
region = PixelRegion(10, 20, 30, 40)
|
||||
assert region.contains(10, 20)
|
||||
assert region.contains(30, 40)
|
||||
assert not region.contains(31, 40)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("coords", [(1, 0, 1, 2), (0, 2, 1, 2), (2, 0, 1, 2)])
|
||||
def test_invalid_bounding_box(coords: tuple[int, int, int, int]) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
BoundingBox(*coords)
|
||||
@@ -0,0 +1,19 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from src.config_loader import PersonStateLoggingSettings
|
||||
from src.models import PersonStateEvent, PersonStateSnapshot, PersonStateSummary
|
||||
from src.person_state_logger import PersonStateLogger
|
||||
|
||||
|
||||
def test_person_state_csvs_bom_json_and_dedup(tmp_path: Path) -> None:
|
||||
logger = PersonStateLogger(PersonStateLoggingSettings(True, True, True, True, 1, str(tmp_path)), "exp", "stamp")
|
||||
logger.open()
|
||||
logger.write_snapshots([PersonStateSnapshot(1, 1, "t", 1., "tracked", metadata={"日本語": "状態"})])
|
||||
logger.write_events([PersonStateEvent("status_changed", 1, 1, "t", 1., "new", "tracked", metadata={"理由": "追跡"})])
|
||||
summary = PersonStateSummary("exp", 1, final_status="completed", completed=True)
|
||||
logger.write_summary(summary); logger.write_summary(summary); logger.close(); logger.close()
|
||||
assert all(path.read_bytes().startswith(b"\xef\xbb\xbf") for path in tmp_path.glob("*.csv"))
|
||||
assert "日本語" in (tmp_path / "stamp_exp_person_state_frames.csv").read_text(encoding="utf-8-sig")
|
||||
with (tmp_path / "stamp_exp_person_state_summary.csv").open(encoding="utf-8-sig", newline="") as stream:
|
||||
assert len(list(csv.DictReader(stream))) == 1
|
||||
@@ -0,0 +1,56 @@
|
||||
from src.config_loader import PersonStateSettings
|
||||
from src.models import (BoundingBox, FrameInfo, HeadPoseResult, TrackEvent, TrackedPerson, TurnFrameResult,
|
||||
VoicePromptDecision, VoicePromptEvent, VoicePromptTrackState)
|
||||
from src.person_state_manager import PersonStateManager
|
||||
|
||||
|
||||
CFG = PersonStateSettings(True, 0., False, False, False, True, True)
|
||||
|
||||
|
||||
def info(frame: int, elapsed: float) -> FrameInfo:
|
||||
return FrameInfo(frame, f"t{frame}", elapsed, 100, 100, 30.)
|
||||
|
||||
|
||||
def person() -> TrackedPerson:
|
||||
return TrackedPerson(1, BoundingBox(1, 1, 20, 40), .9, 0, "person", 10, 20, 10, 40, True, True, 10, 0)
|
||||
|
||||
|
||||
def face() -> HeadPoseResult:
|
||||
return HeadPoseResult(1, 1, "t1", 1., True, True, 0., 10., 0., 6, None, .9)
|
||||
|
||||
|
||||
def turn() -> TurnFrameResult:
|
||||
return TurnFrameResult(1, 1, "t1", 1., True, True, True, 0., 25., 25., 25., "positive", True, True, "weak", .4)
|
||||
|
||||
|
||||
def test_integrates_track_face_turn_voice_and_response() -> None:
|
||||
manager = PersonStateManager(CFG, "exp")
|
||||
track_events = [TrackEvent("region_entered", 1, 1, "t1", 1., 10, 40),
|
||||
TrackEvent("trigger_crossed", 1, 1, "t1", 1., 10, 40, "left_to_right")]
|
||||
decision = VoicePromptDecision(1, 1, "t1", 1., "prompt", True, True, True, False, "prompt_played", "a.wav")
|
||||
voice_events = [VoicePromptEvent("response_window_started", 1, 1, "t1", 1., "prompt", True),
|
||||
VoicePromptEvent("response_detected", 1, 1, "t1", 1.5, "prompt", True,
|
||||
turn_confirmed=True, turn_level="weak", reaction_time_sec=.5)]
|
||||
voice_state = VoicePromptTrackState(1, "prompt", eligible=True, prompt_played=True,
|
||||
response_window_started=True, response_detected=True,
|
||||
reaction_time_sec=.5, response_turn_level="weak")
|
||||
snapshots, events = manager.update([person()], track_events, [face()], [turn()], None,
|
||||
[decision], voice_events, {1: voice_state}, info(1, 1.5))
|
||||
state = snapshots[0]
|
||||
assert state.status == "responded" and state.trigger_crossed
|
||||
assert state.face_detected and state.pose_estimated and state.turn_confirmed
|
||||
assert state.prompt_played and state.response_detected and state.reaction_time_sec == .5
|
||||
assert any(event.event_type == "status_changed" for event in events)
|
||||
summary, _ = manager.finalize_track(1, info(2, 2.), voice_state=voice_state)
|
||||
assert summary and summary.trigger_crossed and summary.response_detected
|
||||
|
||||
|
||||
def test_control_no_response_and_finalize_all() -> None:
|
||||
manager = PersonStateManager(CFG, "exp")
|
||||
decision = VoicePromptDecision(1, 1, "t1", 1., "control", True, True, False, True, "pseudo")
|
||||
events = [VoicePromptEvent("response_window_expired", 1, 2, "t2", 2., "control", pseudo_prompt=True)]
|
||||
snapshots, _ = manager.update([person()], [], [], [], None, [decision], events, None, info(2, 2.))
|
||||
assert snapshots[0].status == "no_response" and snapshots[0].pseudo_prompt
|
||||
summaries, _ = manager.finalize_all(info(3, 3.))
|
||||
assert len(summaries) == 1
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from src.models import (PersonExclusionReason, PersonExperimentStatus, PersonStateEvent,
|
||||
PersonStateSnapshot, PersonStateSummary)
|
||||
|
||||
|
||||
def test_enums_and_models() -> None:
|
||||
assert PersonExperimentStatus.RESPONDED.value == "responded"
|
||||
assert PersonExclusionReason.NO_FACE_DETECTED.value == "no_face_detected"
|
||||
snapshot = PersonStateSnapshot(1, 2, "t", 1., "tracked", metadata={"日本語": True})
|
||||
event = PersonStateEvent("status_changed", 1, 2, "t", 1., "new", "tracked")
|
||||
summary = PersonStateSummary("exp", 1)
|
||||
assert snapshot.metadata["日本語"] and event.new_status == "tracked"
|
||||
assert summary.completed is False and summary.exclusion_reason == "none"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from src.config_loader import PersonStateSettings
|
||||
from src.person_state_rules import choose_status, final_exclusion_reason, normalize_exclusion_reason
|
||||
|
||||
|
||||
CFG = PersonStateSettings(True, .5, False, True, False, True, True)
|
||||
|
||||
|
||||
def test_status_priority_chain() -> None:
|
||||
assert choose_status(tracked=True) == "tracked"
|
||||
assert choose_status(tracked=True, inside_region=True) == "inside_region"
|
||||
assert choose_status(eligible=True) == "eligible"
|
||||
assert choose_status(prompt_played=True) == "prompted"
|
||||
assert choose_status(pseudo_prompt=True) == "pseudo_prompted"
|
||||
assert choose_status(observing=True) == "observing_response"
|
||||
assert choose_status(responded=True, lost=True) == "lost"
|
||||
assert choose_status(responded=True) == "responded"
|
||||
assert choose_status(no_response=True) == "no_response"
|
||||
assert choose_status(finalized=True) == "completed"
|
||||
assert choose_status(excluded=True, finalized=True) == "excluded"
|
||||
|
||||
|
||||
def test_exclusion_rules() -> None:
|
||||
assert final_exclusion_reason(CFG, .1, True, True, None, None, False, True, False) == "track_too_short"
|
||||
assert final_exclusion_reason(CFG, 1., True, False, None, None, False, False, False) == "not_prompt_eligible"
|
||||
assert final_exclusion_reason(CFG, 1., True, True, None, "prompt_failed", False, True, False) == "prompt_failed"
|
||||
assert normalize_exclusion_reason("no_turn_state") == "response_not_evaluable"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from pathlib import Path
|
||||
from main import build_phase11_parser
|
||||
from src.config_loader import load_config
|
||||
from src.phase11_cli import run_diagnostics
|
||||
def test_phase11_parsers()->None:
|
||||
assert build_phase11_parser("pilot-diagnostics").parse_args([]).output==Path("data/analysis")
|
||||
assert build_phase11_parser("calibration-add").parse_args(["--category","camera_position","--before","a","--after","b","--reason","r"]).category=="camera_position"
|
||||
def test_diagnostics_runner_is_camera_free(tmp_path:Path)->None:
|
||||
outputs=run_diagnostics(load_config("config/default.yaml",None),tmp_path,tmp_path/"logs",tmp_path/"out");assert len(outputs)==4
|
||||
@@ -0,0 +1,20 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from src.config_loader import load_config
|
||||
from src.pilot_diagnostics import PilotDiagnostics
|
||||
FIELDS=["session_id","prompt_condition","valid_for_voice_analysis","response_detected","turn_level","pose_estimated_ever","camera_position_note","reaction_time_sec"]
|
||||
def write(root:Path,rows):
|
||||
root.mkdir();p=root/"x_person_analysis_table.csv"
|
||||
with p.open("w",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=FIELDS);w.writeheader();w.writerows(rows)
|
||||
def test_empty_is_insufficient_and_exports(tmp_path:Path)->None:
|
||||
d=PilotDiagnostics(load_config("config/default.yaml",None).pilot_diagnostics);r=d.run(tmp_path,tmp_path/"none");assert r.overall_status=="insufficient_data";paths=d.export(r,tmp_path/"out");assert len(paths)==4 and paths[1].read_bytes().startswith(b"\xef\xbb\xbf")
|
||||
def test_ready_with_target_counts(tmp_path:Path)->None:
|
||||
rows=[]
|
||||
for cond in ("prompt","control"):
|
||||
for i in range(20):rows.append({"session_id":cond,"prompt_condition":cond,"valid_for_voice_analysis":"true","response_detected":"false","turn_level":"none","pose_estimated_ever":"true","camera_position_note":"front","reaction_time_sec":""})
|
||||
logs=tmp_path/"logs";write(logs,rows);r=PilotDiagnostics(load_config("config/default.yaml",None).pilot_diagnostics).run(tmp_path,logs);assert r.overall_status=="ready" and r.ready_for_main_experiment
|
||||
def test_high_not_evaluable_needs_major(tmp_path:Path)->None:
|
||||
rows=[]
|
||||
for cond in ("prompt","control"):
|
||||
for i in range(5):rows.append({"session_id":cond,"prompt_condition":cond,"valid_for_voice_analysis":"true","response_detected":"false","turn_level":"not_evaluable","pose_estimated_ever":"false","camera_position_note":"front","reaction_time_sec":""})
|
||||
logs=tmp_path/"logs";write(logs,rows);assert PilotDiagnostics(load_config("config/default.yaml",None).pilot_diagnostics).run(tmp_path,logs).overall_status=="needs_major_adjustment"
|
||||
@@ -0,0 +1,8 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from src.pilot_session_reporter import PilotSessionReporter
|
||||
def test_report_reads_phase8_and_exports(tmp_path:Path)->None:
|
||||
fields=["condition","total_records","valid_voice_analysis_count","response_rate","mean_reaction_time_sec"]
|
||||
with (tmp_path/"condition_summary.csv").open("w",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=fields);w.writeheader();w.writerows([{"condition":"prompt","total_records":10,"valid_voice_analysis_count":8,"response_rate":.5,"mean_reaction_time_sec":1.2},{"condition":"control","total_records":10,"valid_voice_analysis_count":7,"response_rate":.2,"mean_reaction_time_sec":1.4}])
|
||||
r=PilotSessionReporter();report=r.build_report(tmp_path);assert report.rate_difference==.3;assert "因果関係" in r.export_markdown(report,tmp_path/"pilot.md").read_text(encoding="utf-8")
|
||||
def test_empty_report_is_generated(tmp_path:Path)->None:assert PilotSessionReporter().build_report(tmp_path).total_tracks==0
|
||||
@@ -0,0 +1,5 @@
|
||||
from pathlib import Path
|
||||
from src.models import PilotSessionReport
|
||||
from src.presentation_summary_builder import PresentationSummaryBuilder
|
||||
def test_midterm_summary_contains_required_sections(tmp_path:Path)->None:
|
||||
r=PilotSessionReport("r","now","in","out",0,0,0,0,0,None,None,None,None,None,None,"find","quality","next");path=PresentationSummaryBuilder().build(r,tmp_path,tmp_path/"midterm.md");text=path.read_text(encoding="utf-8");assert "研究目的" in text and "実装済み" in text and "反応率" in text and "因果関係" in text
|
||||
@@ -0,0 +1,12 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from src.config_loader import load_config
|
||||
from src.quality_checker import QualityChecker
|
||||
def write_table(path:Path,rows:list[dict])->None:
|
||||
path.parent.mkdir(parents=True);fields=["prompt_condition","valid_for_voice_analysis","turn_level","pose_estimated_ever","camera_position_note","reaction_time_sec","response_detected"]
|
||||
with path.open("w",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=fields);w.writeheader();w.writerows(rows)
|
||||
def test_missing_files_and_exports(tmp_path:Path)->None:
|
||||
q=QualityChecker(load_config("config/default.yaml",None).quality_check);issues=q.check(tmp_path/"logs",tmp_path/"analysis");assert any(i.issue_type=="missing_file" for i in issues)
|
||||
assert q.export_csv(issues,tmp_path/"quality_issues.csv").read_bytes().startswith(b"\xef\xbb\xbf");assert "ログ品質" in q.export_markdown(issues,tmp_path/"quality.md").read_text(encoding="utf-8")
|
||||
def test_quality_rates_conditions_and_negative_time(tmp_path:Path)->None:
|
||||
table=tmp_path/"logs"/"x_person_analysis_table.csv";write_table(table,[{"prompt_condition":"prompt","valid_for_voice_analysis":"false","turn_level":"not_evaluable","pose_estimated_ever":"false","camera_position_note":"","reaction_time_sec":"-1","response_detected":"true"}]);q=QualityChecker(load_config("config/default.yaml",None).quality_check);types={i.issue_type for i in q.check(tmp_path/"logs",tmp_path/"analysis")};assert {"no_control_records","low_valid_voice_analysis_rate","high_not_evaluable_rate","high_no_pose_rate","missing_camera_position_note","negative_reaction_time"}<=types
|
||||
@@ -0,0 +1,3 @@
|
||||
from src.session_comparator import SessionComparator
|
||||
def test_session_rates_and_camera_missing()->None:
|
||||
rows=[{"session_id":"s","prompt_condition":"prompt","valid_for_voice_analysis":"true","response_detected":"true","turn_level":"weak","pose_estimated_ever":"true","reaction_time_sec":"1","camera_position_note":"front"},{"session_id":"s","prompt_condition":"prompt","valid_for_voice_analysis":"false","response_detected":"false","turn_level":"not_evaluable","pose_estimated_ever":"false"}];s=SessionComparator().summarize(rows)[0];assert s.valid_voice_analysis_rate==.5 and s.not_evaluable_rate==.5 and s.no_pose_estimated_rate==.5
|
||||
@@ -0,0 +1,16 @@
|
||||
from src.config_loader import UnifiedLogSettings
|
||||
from src.models import PersonAnalysisRecord
|
||||
from src.session_summary_builder import SessionSummaryBuilder
|
||||
|
||||
CFG=UnifiedLogSettings(True,"x",True,True,True,True,30,True,True,True,True,False,False,"cam",True)
|
||||
def test_rates_exclude_invalid() -> None:
|
||||
records=[PersonAnalysisRecord("e","s",1,"prompt","prompt",True,response_detected=True,reaction_time_sec=1.,turn_confirmed=True,turn_level="medium",valid_for_turn_analysis=True,valid_for_voice_analysis=True,completed=True),
|
||||
PersonAnalysisRecord("e","s",2,"prompt","prompt",True,response_detected=False,turn_level="subtle",valid_for_turn_analysis=False,valid_for_voice_analysis=False,excluded=True)]
|
||||
out=SessionSummaryBuilder(CFG,"e","s","start").build(records,"end",10.,"prompt",20.)
|
||||
assert out.total_tracks==2 and out.valid_voice_analysis_tracks==1 and out.response_rate==1.
|
||||
assert out.mean_reaction_time_sec==out.median_reaction_time_sec==1.
|
||||
assert out.not_evaluable_count==1 and out.medium_or_higher_count==1 and out.subtle_count==1
|
||||
def test_zero_denominator() -> None:
|
||||
out=SessionSummaryBuilder(CFG,"e","s","start").build([],"end",0.,None)
|
||||
assert out.response_rate is None
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import pytest
|
||||
pd=pytest.importorskip("pandas")
|
||||
from src.config_loader import AnalysisSettings
|
||||
from src.statistical_analyzer import StatisticalAnalyzer
|
||||
|
||||
CFG=AnalysisSettings(True,"in","out",True,True,True,True,"weak","auto","mannwhitney","valid_turn_analysis",False,True,.05)
|
||||
def test_valid_denominator_summaries_and_run()->None:
|
||||
df=pd.DataFrame([{"prompt_condition":"prompt","valid_for_voice_analysis":True,"response_detected":True,"reaction_time_sec":1.,"max_yaw_delta_toward_signage":30.,"turn_level":"weak","excluded":False,"valid_for_turn_analysis":True,"exclusion_reason":"none"},{"prompt_condition":"prompt","valid_for_voice_analysis":False,"response_detected":False,"reaction_time_sec":None,"max_yaw_delta_toward_signage":None,"turn_level":None,"excluded":True,"valid_for_turn_analysis":False,"exclusion_reason":"no_pose_estimated"},{"prompt_condition":"control","valid_for_voice_analysis":True,"response_detected":False,"reaction_time_sec":None,"max_yaw_delta_toward_signage":5.,"turn_level":"none","excluded":False,"valid_for_turn_analysis":True,"exclusion_reason":"none"}])
|
||||
a=StatisticalAnalyzer(CFG);s=a.summarize_conditions(df);assert s[0].valid_voice_analysis_count==1 and s[0].response_rate==1 and s[1].response_rate==0
|
||||
result=a.run_analysis(df,1);assert result.run_summary.total_records==3 and result.exclusion_summaries[0].count==1
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
from src.statistical_tests import compare_numeric,compare_response_counts
|
||||
|
||||
def test_response_auto_fisher_and_rates() -> None:
|
||||
r=compare_response_counts(1,3,0,3,"auto");assert r.test_name=="fisher" and r.rate_difference==1/3
|
||||
def test_response_auto_chi_and_phi() -> None:
|
||||
r=compare_response_counts(30,50,15,50,"auto");assert r.test_name=="chi_square" and r.p_value is not None and r.effect_size_name=="phi"
|
||||
def test_numeric_and_empty() -> None:
|
||||
r=compare_numeric([1.,2.,3.],[3.,4.,5.],"reaction_time_sec");assert r.prompt_count==3 and r.effect_size_name=="rank_biserial"
|
||||
assert compare_numeric([],[],"yaw").p_value is None
|
||||
assert compare_response_counts(0,0,0,0).p_value is None
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
from src.config_loader import CrossingSettings, TrackManagementSettings
|
||||
from src.models import BoundingBox, FrameInfo, PixelRegion, PixelTriggerLine, TrackedPerson
|
||||
from src.track_manager import TrackManager
|
||||
|
||||
|
||||
def management(**overrides: object) -> TrackManagementSettings:
|
||||
values = dict(trajectory_max_points=20, direction_window_points=5, minimum_direction_displacement_pixels=5,
|
||||
region_entry_confirm_frames=2, region_exit_confirm_frames=3,
|
||||
minimum_visible_frames_for_count=3, minimum_duration_sec_for_count=0.1,
|
||||
require_region_entry_for_count=True, require_trigger_crossing_for_count=True)
|
||||
values.update(overrides)
|
||||
return TrackManagementSettings(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def crossing() -> CrossingSettings:
|
||||
return CrossingSettings(True, True, 2, 5, 3, True)
|
||||
|
||||
|
||||
def info(frame: int) -> FrameInfo:
|
||||
return FrameInfo(frame, f"2026-01-01T00:00:{frame:02d}", frame * 0.1, 100, 100, 20.0)
|
||||
|
||||
|
||||
def person(track_id: int, frame: int, x: int, y: int, confirmed: bool = True) -> TrackedPerson:
|
||||
bbox = BoundingBox(max(0, x - 5), max(0, y - 20), min(99, x + 5), min(99, y))
|
||||
return TrackedPerson(track_id, bbox, 0.9, 0, "person", x, y - 10, x, y, False, confirmed, frame, 0)
|
||||
|
||||
|
||||
def manager(**overrides: object) -> TrackManager:
|
||||
return TrackManager(management(**overrides), PixelRegion(20, 20, 80, 80), PixelTriggerLine("vertical", 50), crossing(), 2)
|
||||
|
||||
|
||||
def test_create_initial_entry_trajectory_and_unique_count() -> None:
|
||||
value = manager()
|
||||
events = value.update([person(1, 1, 30, 60)], info(1))
|
||||
assert [event.event_type for event in events] == ["track_created", "region_entered"]
|
||||
value.update([person(1, 2, 40, 60)], info(2))
|
||||
assert len(value.get_state(1).trajectory) == 2 # type: ignore[union-attr]
|
||||
assert value.total_unique_tracks == value.region_visitors == 1
|
||||
|
||||
|
||||
def test_entry_exit_hysteresis_and_no_duplicates() -> None:
|
||||
value = manager()
|
||||
value.update([person(1, 1, 10, 60)], info(1))
|
||||
assert not value.update([person(1, 2, 30, 60)], info(2))
|
||||
assert [e.event_type for e in value.update([person(1, 3, 35, 60)], info(3))] == ["region_entered"]
|
||||
assert "region_exited" not in [e.event_type for e in value.update([person(1, 4, 90, 60)], info(4))]
|
||||
assert "region_exited" not in [e.event_type for e in value.update([person(1, 5, 90, 60)], info(5))]
|
||||
assert "region_exited" in [e.event_type for e in value.update([person(1, 6, 90, 60)], info(6))]
|
||||
assert "region_exited" not in [e.event_type for e in value.update([person(1, 7, 90, 60)], info(7))]
|
||||
|
||||
|
||||
def test_crossing_direction_finalization_and_valid_count() -> None:
|
||||
value = manager()
|
||||
all_events = []
|
||||
for frame, x in enumerate((30, 45, 60), 1):
|
||||
all_events.extend(value.update([person(1, frame, x, 60)], info(frame)))
|
||||
assert sum(event.event_type == "trigger_crossed" for event in all_events) == 1
|
||||
state = value.get_state(1)
|
||||
assert state and state.movement_direction == "left_to_right"
|
||||
events = value.finalize_all(info(4))
|
||||
assert events[0].event_type == "track_finalized"
|
||||
assert value.get_finalized_states()[0].valid_for_counting
|
||||
assert value.completed_passers == 1
|
||||
|
||||
|
||||
def test_stale_track_and_short_track_invalid() -> None:
|
||||
value = manager(minimum_visible_frames_for_count=5)
|
||||
value.update([person(1, 1, 30, 60)], info(1))
|
||||
value.update([], info(2))
|
||||
value.update([], info(3))
|
||||
value.update([], info(4))
|
||||
events = value.finalize_stale_tracks(info(4))
|
||||
assert [event.event_type for event in events] == ["track_lost", "track_finalized"]
|
||||
assert value.get_state(1).invalid_reason == "insufficient_visible_frames" # type: ignore[union-attr]
|
||||
|
||||
|
||||
def test_crossing_direction_wins_when_track_stops_before_finalization() -> None:
|
||||
"""Reproduce a right-to-left crossing followed by stationary tail points."""
|
||||
value = manager(direction_window_points=3, minimum_direction_displacement_pixels=5)
|
||||
all_events = []
|
||||
positions = (70, 55, 40, 40, 40, 40)
|
||||
for frame, x in enumerate(positions, 1):
|
||||
all_events.extend(value.update([person(1, frame, x, 60)], info(frame)))
|
||||
|
||||
crossing_event = next(event for event in all_events if event.event_type == "trigger_crossed")
|
||||
assert crossing_event.direction == "right_to_left"
|
||||
state = value.get_state(1)
|
||||
assert state and state.trigger_crossing_direction == "right_to_left"
|
||||
assert state.movement_direction == "right_to_left"
|
||||
|
||||
finalized_event = value.finalize_all(info(7))[0]
|
||||
finalized_state = value.get_finalized_states()[0]
|
||||
assert finalized_event.direction == "right_to_left"
|
||||
assert finalized_state.movement_direction == "right_to_left"
|
||||
@@ -0,0 +1,58 @@
|
||||
import sys
|
||||
from argparse import Namespace
|
||||
from types import SimpleNamespace
|
||||
|
||||
_inserted_numpy_stub = "numpy" not in sys.modules
|
||||
if _inserted_numpy_stub:
|
||||
sys.modules["numpy"] = SimpleNamespace(ndarray=object)
|
||||
|
||||
from src.config_loader import TrackerSettings
|
||||
from src.tracker import PersonTracker
|
||||
|
||||
if _inserted_numpy_stub:
|
||||
sys.modules.pop("numpy", None)
|
||||
|
||||
|
||||
def settings() -> TrackerSettings:
|
||||
return TrackerSettings(False, "bytetrack", .5, .1, .6, .8, 30, 2, 30, True)
|
||||
|
||||
|
||||
def test_external_rows_are_normalized_and_ids_are_stable() -> None:
|
||||
tracker = PersonTracker(settings())
|
||||
rows = [[-2, 1, 20, 30, 7, .9, 0, 0]]
|
||||
first = tracker._normalize(rows, 100, 100)
|
||||
second = tracker._normalize(rows, 100, 100)
|
||||
assert first[0].track_id == second[0].track_id
|
||||
assert first[0].bbox.x1 == 0
|
||||
assert not first[0].is_confirmed
|
||||
assert second[0].is_confirmed
|
||||
|
||||
|
||||
def test_empty_non_person_and_invalid_boxes_are_filtered() -> None:
|
||||
tracker = PersonTracker(settings())
|
||||
assert tracker._normalize([], 100, 100) == []
|
||||
rows = [[1, 1, 1, 20, 1, .9, 0, 0], [1, 1, 20, 20, 2, .9, 3, 0]]
|
||||
assert tracker._normalize(rows, 100, 100) == []
|
||||
|
||||
|
||||
def test_reset_does_not_reuse_public_ids() -> None:
|
||||
tracker = PersonTracker(settings())
|
||||
first = tracker._normalize([[1, 1, 20, 20, 1, .9, 0, 0]], 100, 100)[0].track_id
|
||||
tracker.reset()
|
||||
second = tracker._normalize([[1, 1, 20, 20, 1, .9, 0, 0]], 100, 100)[0].track_id
|
||||
assert second > first
|
||||
|
||||
|
||||
def test_backend_constructor_supports_new_and_old_ultralytics_apis() -> None:
|
||||
class NewBackend:
|
||||
def __init__(self, args: Namespace) -> None:
|
||||
self.args = args
|
||||
|
||||
class OldBackend:
|
||||
def __init__(self, args: Namespace, frame_rate: int) -> None:
|
||||
self.args, self.frame_rate = args, frame_rate
|
||||
|
||||
args = Namespace(track_buffer=30)
|
||||
assert PersonTracker._create_backend(NewBackend, args, 25).args is args
|
||||
old = PersonTracker._create_backend(OldBackend, args, 25)
|
||||
assert old.args is args and old.frame_rate == 25
|
||||
@@ -0,0 +1,33 @@
|
||||
from collections import deque
|
||||
|
||||
import pytest
|
||||
|
||||
from src.models import BoundingBox, TrackEvent, TrackPoint, TrackedPerson, TrackState
|
||||
|
||||
|
||||
def point(frame: int, x: float) -> TrackPoint:
|
||||
return TrackPoint(float(frame), f"2026-01-01T00:00:0{frame}", frame, x, 10.0, x, 20.0, BoundingBox(0, 0, 10, 20))
|
||||
|
||||
|
||||
def test_tracked_person_and_track_point() -> None:
|
||||
bbox = BoundingBox(1, 2, 11, 22)
|
||||
person = TrackedPerson(3, bbox, 0.9, 0, "person", 6.0, 12.0, 6.0, 22.0, True, True, 4, 0)
|
||||
assert person.track_id == 3
|
||||
assert person.foot_y == 22.0
|
||||
assert point(1, 5).frame_number == 1
|
||||
|
||||
|
||||
def test_track_state_trajectory_is_bounded() -> None:
|
||||
state = TrackState(1, "a", "a", 1, 1, trajectory=deque(maxlen=2))
|
||||
state.trajectory.extend((point(1, 1), point(2, 2), point(3, 3)))
|
||||
assert [item.frame_number for item in state.trajectory] == [2, 3]
|
||||
assert not state.finalized
|
||||
|
||||
|
||||
def test_track_event_and_invalid_values() -> None:
|
||||
event = TrackEvent("track_created", 1, 1, "now", 0.0, 1.0, 2.0, metadata={"日本語": "値"})
|
||||
assert event.metadata["日本語"] == "値"
|
||||
with pytest.raises(ValueError):
|
||||
TrackEvent("bad", 1, 1, "now", 0.0, 0.0, 0.0)
|
||||
with pytest.raises(ValueError):
|
||||
TrackedPerson(-1, BoundingBox(0, 0, 2, 2), 0.5, 0, "person", 1, 1, 1, 2, False, False, 0, 0)
|
||||
@@ -0,0 +1,38 @@
|
||||
import csv
|
||||
import json
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
from src.config_loader import TrackingLoggingSettings
|
||||
from src.models import BoundingBox, FrameInfo, TrackEvent, TrackPoint, TrackedPerson, TrackState
|
||||
from src.tracking_logger import TrackingLogger
|
||||
|
||||
|
||||
def test_all_tracking_csv_logs_and_duplicate_summary(tmp_path: Path) -> None:
|
||||
config = TrackingLoggingSettings(True, True, True, True, 1, str(tmp_path))
|
||||
logger = TrackingLogger(config, "exp", "20260101_000000")
|
||||
logger.open()
|
||||
bbox = BoundingBox(1, 2, 11, 22)
|
||||
tracked = TrackedPerson(1, bbox, .9, 0, "person", 6, 12, 6, 22, True, True, 3, 0)
|
||||
point = TrackPoint(.1, "now", 1, 6, 12, 6, 22, bbox)
|
||||
state = TrackState(1, "now", "later", 1, 2, entered_evaluation_region=True, trigger_crossed=True,
|
||||
movement_direction="left_to_right", total_visible_frames=3, trajectory=deque([point]),
|
||||
finalized=True, is_confirmed=True, first_seen_monotonic=.1, last_seen_monotonic=.5,
|
||||
valid_for_counting=True, invalid_reason="")
|
||||
logger.write_frame_tracks(FrameInfo(1, "now", .1, 100, 100, 20), [tracked], {1: state})
|
||||
event = TrackEvent("trigger_crossed", 1, 1, "now", .1, 6, 22, "left_to_right", {"注記": "通過"})
|
||||
logger.write_events([event])
|
||||
logger.write_summary(state)
|
||||
logger.write_summary(state)
|
||||
logger.close()
|
||||
logger.close()
|
||||
paths = list(tmp_path.glob("*.csv"))
|
||||
assert len(paths) == 3
|
||||
assert all(path.read_bytes().startswith(b"\xef\xbb\xbf") for path in paths)
|
||||
summary = next(path for path in paths if "summary" in path.name)
|
||||
with summary.open(encoding="utf-8-sig", newline="") as stream:
|
||||
assert len(list(csv.DictReader(stream))) == 1
|
||||
events = next(path for path in paths if "events" in path.name)
|
||||
with events.open(encoding="utf-8-sig", newline="") as stream:
|
||||
row = next(csv.DictReader(stream))
|
||||
assert json.loads(row["metadata_json"])["注記"] == "通過"
|
||||
@@ -0,0 +1,99 @@
|
||||
from src.config_loader import TurnDetectionSettings
|
||||
from src.models import FrameInfo, HeadPoseResult
|
||||
from src.turn_detector import TurnDetector
|
||||
|
||||
|
||||
def config(**overrides: object) -> TurnDetectionSettings:
|
||||
values = dict(enabled=True, signage_yaw_direction="positive", baseline_strategy="first_valid",
|
||||
baseline_window_sec=.8, baseline_min_samples=3, subtle_threshold_deg=10,
|
||||
weak_threshold_deg=20, medium_threshold_deg=35, strong_threshold_deg=50,
|
||||
min_subtle_duration_sec=.2, min_turn_duration_sec=.3, max_pose_gap_sec=.3,
|
||||
min_pose_samples_for_evaluation=5, min_face_detection_rate_for_evaluation=.2,
|
||||
min_pose_estimation_rate_for_evaluation=.2, face_appearance_enabled=True,
|
||||
face_missing_before_appearance_sec=.3, weak_or_higher_is_turn=True)
|
||||
values.update(overrides)
|
||||
return TurnDetectionSettings(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def info(frame: int, elapsed: float) -> FrameInfo:
|
||||
return FrameInfo(frame, f"t{frame}", elapsed, 100, 100, 20)
|
||||
|
||||
|
||||
def pose(frame: int, elapsed: float, yaw: float | None, face: bool = True) -> HeadPoseResult:
|
||||
success = yaw is not None
|
||||
return HeadPoseResult(1, frame, f"t{frame}", elapsed, face, success,
|
||||
0 if success else None, yaw, 0 if success else None,
|
||||
6 if face else 0, None, .9 if face else None,
|
||||
"" if success else ("solvepnp_failed" if face else "face_not_detected"))
|
||||
|
||||
|
||||
def feed(detector: TurnDetector, samples: list[tuple[float, float | None, bool]]) -> tuple[list, list]:
|
||||
results, events = [], []
|
||||
for frame, (elapsed, yaw, face) in enumerate(samples, 1):
|
||||
frame_results, new_events = detector.update([pose(frame, elapsed, yaw, face)], info(frame, elapsed))
|
||||
results.extend(frame_results)
|
||||
events.extend(new_events)
|
||||
return results, events
|
||||
|
||||
|
||||
def test_first_valid_baseline_and_threshold_levels() -> None:
|
||||
detector = TurnDetector(config())
|
||||
results, events = feed(detector, [(0, 0, True), (.1, 2, True), (.2, -1, True), (.3, 9, True), (.4, 11, True)])
|
||||
state = detector.get_state(1)
|
||||
assert state and state.baseline_acquired and state.baseline_yaw == 0
|
||||
assert state.baseline_sample_count == 3
|
||||
assert sum(event.event_type == "baseline_acquired" for event in events) == 1
|
||||
assert results[-2].turn_level == "none"
|
||||
assert results[-1].turn_level == "subtle"
|
||||
|
||||
|
||||
def test_baseline_requires_minimum_samples() -> None:
|
||||
detector = TurnDetector(config(baseline_min_samples=3))
|
||||
results, _ = feed(detector, [(0, 0, True), (.1, 1, True)])
|
||||
assert not detector.get_state(1).baseline_acquired # type: ignore[union-attr]
|
||||
assert results[-1].failure_reason == "baseline_not_acquired"
|
||||
|
||||
|
||||
def test_weak_duration_confirms_once_and_tracks_max_delta() -> None:
|
||||
detector = TurnDetector(config())
|
||||
_, events = feed(detector, [(0, 0, True), (.1, 0, True), (.2, 0, True),
|
||||
(.3, 25, True), (.45, 30, True), (.61, 40, True), (.8, 45, True)])
|
||||
state = detector.get_state(1)
|
||||
assert state and state.turn_confirmed and state.max_yaw_delta_toward_signage == 45
|
||||
assert state.strongest_level == "medium"
|
||||
assert sum(event.event_type == "turn_confirmed" for event in events) == 1
|
||||
|
||||
|
||||
def test_short_candidate_and_pose_gap_behaviour() -> None:
|
||||
detector = TurnDetector(config())
|
||||
results, _ = feed(detector, [(0, 0, True), (.1, 0, True), (.2, 0, True),
|
||||
(.3, 25, True), (.4, 25, True), (.5, None, False), (.61, 25, True)])
|
||||
assert results[3].turn_candidate and not results[3].turn_confirmed
|
||||
assert detector.get_state(1).turn_confirmed # type: ignore[union-attr]
|
||||
detector2 = TurnDetector(config())
|
||||
_, events = feed(detector2, [(0, 0, True), (.1, 0, True), (.2, 0, True),
|
||||
(.3, 25, True), (.4, None, False), (.8, None, False)])
|
||||
assert any(event.event_type == "turn_lost" for event in events)
|
||||
|
||||
|
||||
def test_face_appeared_and_finalize_classifications() -> None:
|
||||
detector = TurnDetector(config(min_pose_samples_for_evaluation=3))
|
||||
_, events = feed(detector, [(0, None, False), (.4, 0, True), (.5, 0, True), (.6, 0, True)])
|
||||
state = detector.get_state(1)
|
||||
assert state and state.face_appeared_after_missing
|
||||
assert any(event.event_type == "face_appeared" for event in events)
|
||||
final, final_events = detector.finalize_track(1, info(5, .7))
|
||||
assert final and final.valid_for_turn_analysis and final.exclusion_reason == "evaluable_no_turn"
|
||||
assert final_events[0].event_type == "turn_track_finalized"
|
||||
|
||||
no_pose = TurnDetector(config())
|
||||
no_pose.update([pose(1, 0, None, True)], info(1, 0))
|
||||
final, _ = no_pose.finalize_track(1, info(2, .1))
|
||||
assert final and not final.evaluable and final.exclusion_reason == "no_pose_estimated"
|
||||
|
||||
|
||||
def test_baseline_not_acquired_finalize() -> None:
|
||||
detector = TurnDetector(config(baseline_min_samples=3))
|
||||
feed(detector, [(0, 0, True), (.1, 1, True)])
|
||||
final, _ = detector.finalize_track(1, info(3, .2))
|
||||
assert final and final.exclusion_reason == "baseline_not_acquired"
|
||||
@@ -0,0 +1,30 @@
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from src.config_loader import TurnLoggingSettings
|
||||
from src.models import TurnEvent, TurnFrameResult, TurnTrackState
|
||||
from src.turn_logger import TurnLogger
|
||||
|
||||
|
||||
def test_three_turn_logs_bom_json_and_summary_deduplication(tmp_path: Path) -> None:
|
||||
logger = TurnLogger(TurnLoggingSettings(True, True, True, True, 1, str(tmp_path)), "exp", "stamp")
|
||||
logger.open()
|
||||
frame = TurnFrameResult(1, 2, "now", .2, True, True, True, 0, 25, 25, 25,
|
||||
"positive", True, True, "weak", .3)
|
||||
event = TurnEvent("turn_confirmed", 1, 2, "now", .2, 25, 25, "weak", {"注記": "反応"})
|
||||
state = TurnTrackState(1, total_frames=5, pose_frames=5, face_detected_frames=5,
|
||||
baseline_yaw=0, baseline_acquired=True, turn_confirmed=True,
|
||||
turn_level="weak", evaluable=True, valid_for_turn_analysis=True,
|
||||
exclusion_reason="turn_detected", finalized=True)
|
||||
logger.write_frames([frame])
|
||||
logger.write_events([event])
|
||||
logger.write_summary(state)
|
||||
logger.write_summary(state)
|
||||
logger.close(); logger.close()
|
||||
paths = list(tmp_path.glob("*.csv"))
|
||||
assert len(paths) == 3 and all(path.read_bytes().startswith(b"\xef\xbb\xbf") for path in paths)
|
||||
with next(path for path in paths if "events" in path.name).open(encoding="utf-8-sig", newline="") as stream:
|
||||
assert json.loads(next(csv.DictReader(stream))["metadata_json"])["注記"] == "反応"
|
||||
with next(path for path in paths if "summary" in path.name).open(encoding="utf-8-sig", newline="") as stream:
|
||||
assert len(list(csv.DictReader(stream))) == 1
|
||||
@@ -0,0 +1,24 @@
|
||||
import pytest
|
||||
|
||||
from src.models import TurnEvent, TurnFrameResult, TurnLevel, TurnTrackState
|
||||
|
||||
|
||||
def test_turn_frame_success_failure_and_levels() -> None:
|
||||
result = TurnFrameResult(1, 2, "now", .2, True, True, True, 0, 25, 25, 25,
|
||||
"positive", True, False, TurnLevel.WEAK.value, .1)
|
||||
assert result.yaw_delta_toward_signage == 25
|
||||
failure = TurnFrameResult(1, 3, "later", .3, False, False, False, None, None, None, None,
|
||||
"positive", False, False, "not_evaluable", 0, "no_face")
|
||||
assert failure.turn_level == "not_evaluable"
|
||||
with pytest.raises(ValueError):
|
||||
TurnFrameResult(1, 1, "x", 0, True, True, False, None, 0, None, None,
|
||||
"bad", False, False, "none", 0)
|
||||
|
||||
|
||||
def test_turn_state_rates_and_event() -> None:
|
||||
state = TurnTrackState(1, total_frames=4, pose_frames=2, face_detected_frames=3)
|
||||
assert state.face_detection_rate == .75
|
||||
assert state.pose_estimation_rate == .5
|
||||
assert state.turn_level == "not_evaluable"
|
||||
event = TurnEvent("turn_confirmed", 1, 2, "now", .2, 20, 20, "weak", {"注記": "確認"})
|
||||
assert event.metadata["注記"] == "確認"
|
||||
@@ -0,0 +1,24 @@
|
||||
import pytest
|
||||
|
||||
from src.config_loader import TurnDetectionSettings
|
||||
from src.turn_rules import classify_turn_level, yaw_delta_toward_signage
|
||||
|
||||
|
||||
def config() -> TurnDetectionSettings:
|
||||
return TurnDetectionSettings(True, "positive", "first_valid", .8, 3, 10, 20, 35, 50,
|
||||
.2, .3, .3, 5, .2, .2, True, .3, True)
|
||||
|
||||
|
||||
def test_signage_delta_directions() -> None:
|
||||
assert yaw_delta_toward_signage(30, 10, "positive") == (20, 20)
|
||||
assert yaw_delta_toward_signage(-10, 10, "negative") == (-20, 20)
|
||||
with pytest.raises(ValueError):
|
||||
yaw_delta_toward_signage(1, 0, "bad")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("delta", "level"), [
|
||||
(9.99, "none"), (10, "subtle"), (19.99, "subtle"), (20, "weak"),
|
||||
(34.99, "weak"), (35, "medium"), (49.99, "medium"), (50, "strong"),
|
||||
])
|
||||
def test_level_boundaries(delta: float, level: str) -> None:
|
||||
assert classify_turn_level(delta, config()) == level
|
||||
@@ -0,0 +1,18 @@
|
||||
from src.config_loader import TurnDetectionSettings
|
||||
from src.models import FrameInfo, TrackEvent
|
||||
from src.turn_detector import TurnDetector
|
||||
from src.turn_track_manager import TurnTrackManager
|
||||
|
||||
|
||||
def test_track_finalization_creates_not_evaluable_summary() -> None:
|
||||
config = TurnDetectionSettings(True, "positive", "first_valid", .8, 3, 10, 20, 35, 50,
|
||||
.2, .3, .3, 5, .2, .2, True, .3, True)
|
||||
detector = TurnDetector(config)
|
||||
detector.register_tracks([7])
|
||||
manager = TurnTrackManager(detector)
|
||||
info = FrameInfo(10, "now", 1.0, 100, 100, 20)
|
||||
track_event = TrackEvent("track_finalized", 7, 10, "now", 1.0, 0, 0)
|
||||
states, events = manager.finalize_from_track_events([track_event], info)
|
||||
assert states[0].turn_level == "not_evaluable"
|
||||
assert states[0].exclusion_reason == "no_face_detected"
|
||||
assert events[0].event_type == "turn_track_finalized"
|
||||
@@ -0,0 +1,22 @@
|
||||
from src.config_loader import UnifiedLogSettings
|
||||
from src.models import FrameInfo, PersonStateEvent, TrackEvent, TurnEvent, VoicePromptEvent
|
||||
from src.unified_event_builder import UnifiedEventBuilder
|
||||
|
||||
def cfg(important:bool=False)->UnifiedLogSettings:
|
||||
return UnifiedLogSettings(True,"x",True,True,True,True,30,True,True,True,True,False,important,"",True)
|
||||
|
||||
def test_converts_sources_and_sorts() -> None:
|
||||
b=UnifiedEventBuilder(cfg(),"exp","session"); f=FrameInfo(2,"t",2.,10,10,1.)
|
||||
items=[]
|
||||
items+=b.from_track_events([TrackEvent("trigger_crossed",1,2,"t",2.,1,1)],f)
|
||||
items+=b.from_turn_events([TurnEvent("turn_confirmed",1,2,"t",2.,1.,20.,"weak")],f)
|
||||
items+=b.from_voice_events([VoicePromptEvent("prompt_played",1,2,"t",2.,"prompt",True)],f)
|
||||
items+=b.from_person_state_events([PersonStateEvent("responded",1,2,"t",2.,"observing","responded")],f)
|
||||
assert {x.source for x in items}=={"tracking","turn","voice","person_state"}
|
||||
assert len({x.event_id for x in items})==4
|
||||
assert b.stable_sort(items)==sorted(items,key=lambda x:(x.elapsed_time_sec,x.frame_number,x.source,x.event_id))
|
||||
|
||||
def test_important_only_filters() -> None:
|
||||
b=UnifiedEventBuilder(cfg(True),"e","s");f=FrameInfo(1,"t",1.,1,1,1.)
|
||||
assert not b.from_track_events([TrackEvent("track_created",1,1,"t",1.,1,1)],f)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from pathlib import Path
|
||||
from src.config_loader import UnifiedLogSettings
|
||||
from src.models import UnifiedEvent,PersonAnalysisRecord,SessionSummary,LogConsistencyIssue
|
||||
from src.unified_event_logger import UnifiedEventLogger
|
||||
|
||||
def test_four_csvs_bom_json_dedup(tmp_path:Path)->None:
|
||||
c=UnifiedLogSettings(True,str(tmp_path),True,True,True,True,1,True,True,True,True,False,False,"",True)
|
||||
l=UnifiedEventLogger(c,"exp","sid","stamp");l.open()
|
||||
e=UnifiedEvent("exp","sid","id","system","x",None,1,"t",1.,metadata={"日本語":"値"})
|
||||
l.write_events([e,e]);r=PersonAnalysisRecord("exp","sid",1);l.write_analysis([r,r]);s=SessionSummary("exp","sid","t");l.write_session(s);l.write_session(s);l.write_issues([LogConsistencyIssue("unknown","warning",1,None,"m",{"日":"本"})]);l.close();l.close()
|
||||
assert len(list(tmp_path.glob("*.csv")))==4
|
||||
assert all(p.read_bytes().startswith(b"\xef\xbb\xbf") for p in tmp_path.glob("*.csv"))
|
||||
assert "日本語" in (tmp_path/"stamp_exp_unified_events.csv").read_text(encoding="utf-8-sig")
|
||||
@@ -0,0 +1,11 @@
|
||||
import pytest
|
||||
from src.models import UnifiedEvent, PersonAnalysisRecord, SessionSummary, LogConsistencyIssue
|
||||
|
||||
def test_models_and_validation() -> None:
|
||||
event=UnifiedEvent("e","s","id","tracking","created",1,1,"t",0.,metadata={"日本語":True})
|
||||
assert event.metadata["日本語"]
|
||||
assert PersonAnalysisRecord("e","s",1).prompt_condition=="unknown"
|
||||
assert SessionSummary("e","s","t").total_tracks==0
|
||||
assert LogConsistencyIssue("unknown","warning",None,None,"m").severity=="warning"
|
||||
with pytest.raises(ValueError): UnifiedEvent("e","s","id","bad","x",None,0,"t",0.)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import pytest
|
||||
pytest.importorskip("PySide6")
|
||||
np=pytest.importorskip("numpy")
|
||||
from src.gui.video_widget import bgr_to_rgb
|
||||
def test_bgr_to_rgb()->None:
|
||||
value=np.array([[[1,2,3]]],dtype=np.uint8);assert bgr_to_rgb(value).tolist()==[[[3,2,1]]]
|
||||
@@ -0,0 +1,26 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from src.config_loader import VoiceLoggingSettings
|
||||
from src.models import VoicePromptDecision, VoicePromptEvent, VoicePromptTrackState
|
||||
from src.voice_logger import VoiceLogger
|
||||
|
||||
|
||||
def test_voice_csvs_bom_json_control_and_dedup(tmp_path: Path) -> None:
|
||||
config = VoiceLoggingSettings(True, True, True, True, 1, str(tmp_path))
|
||||
logger = VoiceLogger(config, "exp", "stamp")
|
||||
logger.open()
|
||||
logger.write_decisions([VoicePromptDecision(1, 2, "t", 1., "control", True, True, False, True, "pseudo", None)])
|
||||
logger.write_events([VoicePromptEvent("pseudo_prompt_triggered", 1, 2, "t", 1., "control",
|
||||
pseudo_prompt=True, metadata={"日本語": "声掛けなし"})])
|
||||
state = VoicePromptTrackState(1, "control", eligible=True, pseudo_prompt=True, finalized=True)
|
||||
logger.write_summary(state)
|
||||
logger.write_summary(state)
|
||||
logger.close()
|
||||
logger.close()
|
||||
for path in tmp_path.glob("*.csv"):
|
||||
assert path.read_bytes().startswith(b"\xef\xbb\xbf")
|
||||
event_text = (tmp_path / "stamp_exp_voice_events.csv").read_text(encoding="utf-8-sig")
|
||||
assert "日本語" in event_text and "声掛けなし" in event_text
|
||||
with (tmp_path / "stamp_exp_voice_summary.csv").open(encoding="utf-8-sig", newline="") as stream:
|
||||
assert len(list(csv.DictReader(stream))) == 1
|
||||
@@ -0,0 +1,73 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
from src.config_loader import VoicePromptSettings
|
||||
from src.models import BoundingBox, FrameInfo, TrackEvent, TrackedPerson, TurnFrameResult
|
||||
from src.voice_prompt_controller import VoicePromptController
|
||||
|
||||
|
||||
def cfg(mode: str = "prompt", **kw: object) -> VoicePromptSettings:
|
||||
values = dict(enabled=True, mode=mode, trigger_strategy="trigger_line_crossing", require_confirmed_track=True,
|
||||
require_inside_region=True, skip_if_already_turned=True, one_prompt_per_track=True,
|
||||
global_cooldown_sec=3., minimum_track_age_sec=0., response_window_sec=3.,
|
||||
fixed_position_axis="x", fixed_position_value=640., fixed_position_direction="decreasing")
|
||||
values.update(kw)
|
||||
return VoicePromptSettings(**values)
|
||||
|
||||
|
||||
def info(frame: int, elapsed: float) -> FrameInfo:
|
||||
return FrameInfo(frame, f"t{frame}", elapsed, 100, 100, 30.)
|
||||
|
||||
|
||||
def person(track_id: int = 1) -> TrackedPerson:
|
||||
return TrackedPerson(track_id, BoundingBox(1, 1, 20, 40), .9, 0, "person", 10, 20, 10, 40, True, True, 10, 0)
|
||||
|
||||
|
||||
def crossing(track_id: int = 1) -> TrackEvent:
|
||||
return TrackEvent("trigger_crossed", track_id, 1, "t", 1., 10, 40)
|
||||
|
||||
|
||||
def turn(frame: int, elapsed: float, confirmed: bool = True) -> TurnFrameResult:
|
||||
return TurnFrameResult(1, frame, f"t{frame}", elapsed, True, True, True, 0., 25., 25., 25., "positive",
|
||||
True, confirmed, "weak", .4, "")
|
||||
|
||||
|
||||
def test_prompt_success_duplicate_and_response() -> None:
|
||||
audio = Mock(audio_file="a.wav")
|
||||
audio.play.return_value = True
|
||||
controller = VoicePromptController(cfg(), audio)
|
||||
decisions, events = controller.update([person()], [crossing()], [], info(1, 1.))
|
||||
assert decisions[0].prompt_played
|
||||
assert {e.event_type for e in events} >= {"prompt_played", "response_window_started"}
|
||||
controller.update([person()], [crossing()], [], info(2, 1.1))
|
||||
assert audio.play.call_count == 1
|
||||
_, events = controller.update([person()], [], [turn(3, 1.8)], info(3, 1.8))
|
||||
response = next(e for e in events if e.event_type == "response_detected")
|
||||
assert response.reaction_time_sec == .8
|
||||
|
||||
|
||||
def test_prompt_failure_control_and_expiration() -> None:
|
||||
failed = Mock(audio_file="missing.wav")
|
||||
failed.play.return_value = False
|
||||
controller = VoicePromptController(cfg(), failed)
|
||||
_, events = controller.update([person()], [crossing()], [], info(1, 1.))
|
||||
assert "prompt_failed" in {event.event_type for event in events}
|
||||
control_audio = Mock(audio_file="a.wav")
|
||||
control = VoicePromptController(cfg("control", response_window_sec=1.), control_audio)
|
||||
decisions, events = control.update([person()], [crossing()], [], info(1, 1.))
|
||||
assert decisions[0].pseudo_prompt and not decisions[0].prompt_played
|
||||
control_audio.play.assert_not_called()
|
||||
_, events = control.update([person()], [], [], info(2, 2.1))
|
||||
assert "response_window_expired" in {event.event_type for event in events}
|
||||
|
||||
|
||||
def test_cooldown_and_finalize_all() -> None:
|
||||
audio = Mock(audio_file="a.wav")
|
||||
audio.play.return_value = True
|
||||
controller = VoicePromptController(cfg(), audio)
|
||||
controller.update([person(1)], [crossing(1)], [], info(1, 1.))
|
||||
decisions, _ = controller.update([person(2)], [crossing(2)], [], info(2, 2.))
|
||||
assert decisions[0].reason == "global_cooldown"
|
||||
states, events = controller.finalize_all(info(3, 5.))
|
||||
assert len(states) == 2 and all(state.finalized for state in states)
|
||||
assert all(event.event_type == "voice_track_finalized" for event in events)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from src.config_loader import VoicePromptSettings
|
||||
from src.models import BoundingBox, TrackEvent, TrackedPerson, VoicePromptTrackState
|
||||
from src.voice_rules import eligibility_reason, trigger_matches
|
||||
|
||||
|
||||
def cfg(strategy: str = "trigger_line_crossing", **kw: object) -> VoicePromptSettings:
|
||||
values = dict(enabled=True, mode="prompt", trigger_strategy=strategy, require_confirmed_track=True,
|
||||
require_inside_region=True, skip_if_already_turned=True, one_prompt_per_track=True,
|
||||
global_cooldown_sec=3., minimum_track_age_sec=0., response_window_sec=3.,
|
||||
fixed_position_axis="x", fixed_position_value=50., fixed_position_direction="increasing")
|
||||
values.update(kw)
|
||||
return VoicePromptSettings(**values)
|
||||
|
||||
|
||||
def person(x: float = 60, confirmed: bool = True, inside: bool = True) -> TrackedPerson:
|
||||
return TrackedPerson(1, BoundingBox(40, 10, 80, 100), .9, 0, "person", 60, 55, x, 100,
|
||||
inside, confirmed, 10, 0)
|
||||
|
||||
|
||||
def event(kind: str) -> TrackEvent:
|
||||
return TrackEvent(kind, 1, 1, "t", 1., 60, 100)
|
||||
|
||||
|
||||
def test_event_and_position_triggers() -> None:
|
||||
assert trigger_matches(cfg(), person(), [event("trigger_crossed")])
|
||||
assert trigger_matches(cfg("region_entry"), person(), [event("region_entered")])
|
||||
assert trigger_matches(cfg("fixed_x_position"), person(), [])
|
||||
|
||||
|
||||
def test_eligibility_guards() -> None:
|
||||
state = VoicePromptTrackState(1, "prompt")
|
||||
assert eligibility_reason(cfg(), person(confirmed=False), state, None, 0) == "unconfirmed_track"
|
||||
assert eligibility_reason(cfg(), person(inside=False), state, None, 0) == "outside_region"
|
||||
assert eligibility_reason(cfg(), person(), state, None, 1) == "global_cooldown"
|
||||
state.prompt_attempted = True
|
||||
assert eligibility_reason(cfg(), person(), state, None, 0) == "already_prompted"
|
||||
|
||||
Reference in new issue
Block a user