package fall import ( "math" "testing" "silverpose/v2/internal/pose" ) func TestEngineConfirmsPersistentHorizontalPoseAtV1Delay(t *testing.T) { engine, err := NewEngine(EngineConfig{ KeypointConfidenceThreshold: 0.4, SuspectWindowSeconds: 0.5, ConfirmWindowSeconds: 1.8, RecoveryWindowSeconds: 2.0, CooldownSeconds: 10.0, RequireRapidDrop: false, RequireLowerBody: false, HorizontalAngleThresholdDegrees: 45.0, ConfigVersion: "cfg-v1-contract", SessionID: "regression", }) if err != nil { t.Fatalf("NewEngine returned an error: %v", err) } engine.Process(Frame{Timestamp: 0.0, Width: 180, Height: 180, Poses: []pose.PersonPose{testPose(false)}}) engine.Process(Frame{Timestamp: 0.1, Width: 180, Height: 180, Poses: []pose.PersonPose{testPose(true)}}) result := engine.Process(Frame{Timestamp: 1.91, Width: 180, Height: 180, Poses: []pose.PersonPose{testPose(true)}}) if len(result.Events) != 1 { t.Fatalf("events = %+v, want one confirmation", result.Events) } event := result.Events[0] if event.TrackID != "P-0001" || math.Abs(event.LatencySeconds-1.81) > 1e-9 || event.ConfigVersion != "cfg-v1-contract" { t.Fatalf("event = %+v", event) } if got := engine.StateOf("P-0001"); got != Confirmed { t.Fatalf("state = %s, want CONFIRMED", got) } } func TestEngineDoesNotConfirmWhenTrackIsMissing(t *testing.T) { engine, err := NewEngine(testConfig()) if err != nil { t.Fatalf("NewEngine returned an error: %v", err) } engine.Process(Frame{Timestamp: 0.0, Width: 180, Height: 180, Poses: []pose.PersonPose{testPose(false)}}) engine.Process(Frame{Timestamp: 0.1, Width: 180, Height: 180, Poses: []pose.PersonPose{testPose(true)}}) engine.Process(Frame{Timestamp: 0.2, Width: 180, Height: 180}) result := engine.Process(Frame{Timestamp: 1.9, Width: 180, Height: 180, Poses: []pose.PersonPose{testPose(true)}}) if len(result.Events) != 0 { t.Fatalf("events = %+v, want none after a missing track", result.Events) } } func TestEngineDoesNotAlarmForUprightSequence(t *testing.T) { engine, err := NewEngine(testConfig()) if err != nil { t.Fatalf("NewEngine returned an error: %v", err) } for _, timestamp := range []float64{0.0, 0.6, 1.2, 1.8, 2.4} { result := engine.Process(Frame{Timestamp: timestamp, Width: 180, Height: 180, Poses: []pose.PersonPose{testPose(false)}}) if len(result.Events) != 0 { t.Fatalf("timestamp %v emitted %+v for upright pose", timestamp, result.Events) } } } func testConfig() EngineConfig { return EngineConfig{ KeypointConfidenceThreshold: 0.4, SuspectWindowSeconds: 0.5, ConfirmWindowSeconds: 1.8, RecoveryWindowSeconds: 2.0, CooldownSeconds: 10.0, RequireRapidDrop: false, RequireLowerBody: false, HorizontalAngleThresholdDegrees: 45.0, ConfigVersion: "cfg-v1-contract", SessionID: "regression", } } func testPose(horizontal bool) pose.PersonPose { person := pose.PersonPose{Box: pose.Box{Left: 20, Top: 20, Right: 160, Bottom: 160}, Confidence: 0.9} for index := range person.Keypoints { person.Keypoints[index] = pose.Keypoint{X: float32(index), Y: float32(index), Confidence: 0.9} } if horizontal { person.Keypoints[5] = pose.Keypoint{X: 20, Y: 80, Confidence: 0.9} person.Keypoints[6] = pose.Keypoint{X: 30, Y: 80, Confidence: 0.9} person.Keypoints[11] = pose.Keypoint{X: 70, Y: 100, Confidence: 0.9} person.Keypoints[12] = pose.Keypoint{X: 80, Y: 100, Confidence: 0.9} } else { person.Keypoints[5] = pose.Keypoint{X: 30, Y: 10, Confidence: 0.9} person.Keypoints[6] = pose.Keypoint{X: 40, Y: 10, Confidence: 0.9} person.Keypoints[11] = pose.Keypoint{X: 30, Y: 30, Confidence: 0.9} person.Keypoints[12] = pose.Keypoint{X: 40, Y: 30, Confidence: 0.9} } return person }