Files
httprunner/internal/simulation/click_api.go
2025-07-30 11:18:26 +08:00

564 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package simulation
import (
"encoding/json"
"fmt"
"math"
"math/rand"
"time"
"github.com/httprunner/httprunner/v5/uixt/types"
)
// ClickRequest 点击请求参数
type ClickRequest struct {
X float64 `json:"x"` // 点击X坐标
Y float64 `json:"y"` // 点击Y坐标
DeviceID int `json:"device_id"` // 设备ID
Pressure float64 `json:"pressure"` // 压力值
Size float64 `json:"size"` // 接触面积
}
// ClickResponse 点击响应结果
type ClickResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Points []ClickPoint `json:"points"`
Metrics ClickMetrics `json:"metrics"`
}
// ClickMetrics 点击指标
type ClickMetrics struct {
TotalDuration int64 `json:"total_duration_ms"` // 总持续时间(毫秒)
PointCount int `json:"point_count"` // 轨迹点数量
MaxDeviation float64 `json:"max_deviation"` // 最大偏移距离
AverageInterval float64 `json:"average_interval_ms"` // 平均采样间隔
}
// ClickPoint 点击轨迹点
type ClickPoint struct {
Timestamp int64 `json:"timestamp"` // 时间戳(毫秒)
X float64 `json:"x"` // X坐标
Y float64 `json:"y"` // Y坐标
DeviceID int `json:"device_id"` // 设备ID
Pressure float64 `json:"pressure"` // 压力值
Size float64 `json:"size"` // 接触面积
Action int `json:"action"` // 动作类型(0=按下,1=抬起,2=移动)
EventTime int64 `json:"event_time"` // 相对第一个点的时间(ms)第一个点为0
}
// ClickConfig 点击配置参数
type ClickConfig struct {
MinDuration int64 // 最小持续时间(毫秒)
MaxDuration int64 // 最大持续时间(毫秒)
MinPoints int // 最小点数
MaxPoints int // 最大点数
MaxDeviation float64 // 最大坐标偏移(像素)
NoiseLevel float64 // 噪声级别
}
// DefaultClickConfig 默认配置
var DefaultClickConfig = ClickConfig{
MinDuration: 40,
MaxDuration: 90,
MinPoints: 4, // 增加最小点数从3到4确保至少有2个MOVE事件
MaxPoints: 6,
MaxDeviation: 2.0,
NoiseLevel: 0.5,
}
// ClickSimulatorAPI 点击仿真API
type ClickSimulatorAPI struct {
rand *rand.Rand
config ClickConfig
}
// TestCase 测试用例
type ClickTestCase struct {
Name string
X float64
Y float64
DeviceID int
Pressure float64
Size float64
}
// NewClickSimulatorAPI 创建新的点击仿真API
func NewClickSimulatorAPI(config *ClickConfig) *ClickSimulatorAPI {
if config == nil {
config = &DefaultClickConfig
}
return &ClickSimulatorAPI{
rand: rand.New(rand.NewSource(time.Now().UnixNano())),
config: *config,
}
}
// GenerateClick 生成点击轨迹
func (api *ClickSimulatorAPI) GenerateClick(req ClickRequest) ClickResponse {
// 验证输入参数
if err := api.validateRequest(req); err != nil {
return ClickResponse{
Success: false,
Message: err.Error(),
}
}
// 生成点击轨迹
points := api.generateClickPoints(req)
// 计算指标
metrics := api.calculateMetrics(points, req.X, req.Y)
return ClickResponse{
Success: true,
Points: points,
Metrics: metrics,
}
}
// validateRequest 验证请求参数
func (api *ClickSimulatorAPI) validateRequest(req ClickRequest) error {
if req.X < 0 || req.Y < 0 {
return fmt.Errorf("coordinates must be non-negative")
}
if req.DeviceID < 0 {
return fmt.Errorf("device_id must be non-negative")
}
return nil
}
// generateClickPoints 生成点击轨迹点
func (api *ClickSimulatorAPI) generateClickPoints(req ClickRequest) []ClickPoint {
// 计算点击参数
duration := api.calculateDuration()
pointCount := api.calculatePointCount()
// 生成时间戳序列
timestamps := api.generateTimestamps(duration, pointCount)
// 生成轨迹点
points := make([]ClickPoint, pointCount)
// 生成size变化曲线基于真实数据分析
sizeValues := api.generateSizeValues(pointCount, req.Size)
// 生成压力值序列
pressureValues := api.generatePressureValues(pointCount, req.Pressure)
// 生成坐标偏移序列
xOffsets, yOffsets := api.generateCoordinateOffsets(pointCount)
baseTimestamp := timestamps[0]
for i := 0; i < pointCount; i++ {
// 计算当前坐标(添加轻微偏移)
currentX := req.X + xOffsets[i]
currentY := req.Y + yOffsets[i]
// 确定动作类型
var action int
if i == 0 {
action = 0 // 按下
} else if i == pointCount-1 {
action = 1 // 抬起
} else {
action = 2 // 移动
}
eventTime := timestamps[i] - baseTimestamp
points[i] = ClickPoint{
Timestamp: timestamps[i],
X: currentX,
Y: currentY,
DeviceID: req.DeviceID,
Pressure: pressureValues[i],
Size: sizeValues[i],
Action: action,
EventTime: eventTime,
}
}
return points
}
// generatePressureValues 生成pressure值序列基于用户输入的压力值动态仿真点击操作
func (api *ClickSimulatorAPI) generatePressureValues(pointCount int, basePressure float64) []float64 {
pressures := make([]float64, pointCount)
// 如果用户没有提供压力值,使用默认值
if basePressure <= 0 {
basePressure = 1 // 默认压力值
}
// 特殊处理当压力值为1时保持恒定不变
if basePressure == 1 {
for i := 0; i < pointCount; i++ {
pressures[i] = 1.0
}
return pressures
}
// 将整数压力值转换为浮点数
baseP := float64(basePressure)
// 基于真实点击数据观察的压力变化规律:
// 点击操作的pressure变化特点快速上升→短暂保持峰值→快速下降
// 1. 起始压力基础压力的95%-105%
// 2. 峰值压力基础压力的102%-108% (相对滑动,点击的峰值增幅较小)
// 3. 结束压力基础压力的25%-35% (快速下降到较低值)
startPressureRatio := 0.95 + api.rand.Float64()*0.10 // 95%-105%
peakPressureRatio := 1.02 + api.rand.Float64()*0.06 // 102%-108%
endPressureRatio := 0.25 + api.rand.Float64()*0.10 // 25%-35%
startPressure := baseP * startPressureRatio
peakPressure := baseP * peakPressureRatio
endPressure := baseP * endPressureRatio
// 点击操作的峰值通常出现在早期(第2-3个点)
var peakIndex int
if pointCount <= 3 {
peakIndex = 1 // 对于短序列峰值在第2个点
} else {
peakIndex = 1 + api.rand.Intn(2) // 峰值在第2或第3个点
}
if peakIndex >= pointCount {
peakIndex = pointCount - 2
}
// 确保压力值在合理范围内0.5-15.0
//if startPressure < 0.5 {
// startPressure = 0.5
//}
//if peakPressure > 15.0 {
// peakPressure = 15.0
//}
//if endPressure < 0.5 {
// endPressure = 0.5
//}
for i := 0; i < pointCount; i++ {
var pressure float64
if i == 0 {
// 第一个点:起始压力
pressure = startPressure
} else if i <= peakIndex {
// 上升到峰值阶段
pressure = peakPressure
} else if i == pointCount-1 {
// 最后一个点:结束压力
pressure = endPressure
} else {
// 从峰值下降到结束压力的过渡阶段
t := float64(i-peakIndex) / float64(pointCount-1-peakIndex)
pressure = peakPressure + (endPressure-peakPressure)*t
}
// 添加随机噪声±3%),点击操作的噪声相对较小
noiseRange := pressure * 0.03
noise := (api.rand.Float64() - 0.5) * noiseRange
pressure += noise
// 确保pressure在合理范围内
//if pressure < 0.5 {
// pressure = 0.5 + api.rand.Float64()*0.3
//}
//if pressure > 15.0 {
// pressure = 14.5 + api.rand.Float64()*0.5
//}
// 保留两位小数精度
pressures[i] = math.Round(pressure*100) / 100
}
return pressures
}
// calculateDuration 计算点击持续时间
func (api *ClickSimulatorAPI) calculateDuration() int64 {
// 基于真实数据的持续时间算法
baseDuration := float64(api.config.MinDuration+api.config.MaxDuration) / 2
randomFactor := api.rand.Float64()*float64(api.config.MaxDuration-api.config.MinDuration) -
float64(api.config.MaxDuration-api.config.MinDuration)/2
duration := baseDuration + randomFactor
if duration < float64(api.config.MinDuration) {
duration = float64(api.config.MinDuration)
}
if duration > float64(api.config.MaxDuration) {
duration = float64(api.config.MaxDuration)
}
return int64(duration)
}
// calculatePointCount 计算轨迹点数量
func (api *ClickSimulatorAPI) calculatePointCount() int {
// 基于真实数据分析点击通常有3-6个点
count := api.config.MinPoints + api.rand.Intn(api.config.MaxPoints-api.config.MinPoints+1)
return count
}
// generateTimestamps 生成时间戳序列
func (api *ClickSimulatorAPI) generateTimestamps(duration int64, pointCount int) []int64 {
baseTime := time.Now().UnixMilli()
timestamps := make([]int64, pointCount)
timestamps[0] = baseTime
if pointCount == 1 {
return timestamps
}
// 基于真实数据的时间间隔模式
for i := 1; i < pointCount; i++ {
// 时间间隔:前期较短,后期可能较长
var intervalRatio float64
if i == 1 {
// 第一个间隔较短 (8-30ms)
intervalRatio = 0.1 + api.rand.Float64()*0.2 // 10%-30%
} else if i == pointCount-1 {
// 最后一个间隔可能较短
intervalRatio = 0.1 + api.rand.Float64()*0.15 // 10%-25%
} else {
// 中间间隔相对均匀
intervalRatio = 0.15 + api.rand.Float64()*0.25 // 15%-40%
}
interval := int64(float64(duration) * intervalRatio)
timestamps[i] = timestamps[i-1] + interval
}
// 确保最后一个时间戳不超过总持续时间
if timestamps[pointCount-1] > baseTime+duration {
timestamps[pointCount-1] = baseTime + duration
}
return timestamps
}
// generateSizeValues 生成size值序列基于真实数据分析
func (api *ClickSimulatorAPI) generateSizeValues(pointCount int, baseSize float64) []float64 {
sizes := make([]float64, pointCount)
// 如果baseSize为0使用默认值
if baseSize == 0 {
baseSize = 0.043 // 默认size值基于真实数据平均值
}
// 动态计算size范围基于baseSize的值来适应不同设备
var minSize, maxSize float64
if baseSize < 1.0 {
// 小数值范围如0.043),使用原有逻辑
minSize = 0.035
maxSize = 0.051
// 确保baseSize在合理范围内
if baseSize < minSize {
baseSize = minSize + api.rand.Float64()*(maxSize-minSize)*0.3
}
if baseSize > maxSize {
baseSize = maxSize - api.rand.Float64()*(maxSize-minSize)*0.3
}
} else {
// 大数值范围如几十或几百基于baseSize动态计算范围
// 允许在baseSize的±20%范围内变化
minSize = baseSize * 0.8
maxSize = baseSize * 1.2
}
for i := 0; i < pointCount; i++ {
// 基础size值随点击进度变化
var sizeModifier float64
if i == 0 {
// 开始时:可能较小
sizeModifier = 0.85 + api.rand.Float64()*0.3 // 0.85-1.15倍
} else if i == pointCount-1 {
// 结束时:可能减小(手指抬起)
sizeModifier = 0.8 + api.rand.Float64()*0.25 // 0.8-1.05倍
} else {
// 中间过程:可能增大(压力增加)
sizeModifier = 0.95 + api.rand.Float64()*0.25 // 0.95-1.2倍
}
// 应用变化
sizes[i] = baseSize * sizeModifier
// 确保在合理范围内
if sizes[i] < minSize {
sizes[i] = minSize
}
if sizes[i] > maxSize {
sizes[i] = maxSize
}
// 添加轻微随机噪声噪声大小根据baseSize动态调整
var noiseLevel float64
if baseSize < 1.0 {
noiseLevel = 0.002 // 小数值使用固定的小噪声
} else {
noiseLevel = baseSize * 0.01 // 大数值使用baseSize的1%作为噪声
}
sizes[i] += api.addNoise(noiseLevel)
// 最终范围检查
if sizes[i] < minSize {
sizes[i] = minSize
}
if sizes[i] > maxSize {
sizes[i] = maxSize
}
}
return sizes
}
// generateCoordinateOffsets 生成坐标偏移序列
func (api *ClickSimulatorAPI) generateCoordinateOffsets(pointCount int) ([]float64, []float64) {
xOffsets := make([]float64, pointCount)
yOffsets := make([]float64, pointCount)
// 第一个点不偏移
xOffsets[0] = 0
yOffsets[0] = 0
if pointCount == 1 {
return xOffsets, yOffsets
}
// 基于真实数据分析,点击时会有轻微的移动
for i := 1; i < pointCount; i++ {
// 累积偏移,模拟手指的轻微移动
maxOffset := api.config.MaxDeviation * float64(i) / float64(pointCount-1)
// 添加随机偏移
xOffsets[i] = xOffsets[i-1] + api.addNoise(maxOffset*0.5)
yOffsets[i] = yOffsets[i-1] + api.addNoise(maxOffset*0.5)
// 限制总偏移量
if math.Abs(xOffsets[i]) > api.config.MaxDeviation {
if xOffsets[i] > 0 {
xOffsets[i] = api.config.MaxDeviation
} else {
xOffsets[i] = -api.config.MaxDeviation
}
}
if math.Abs(yOffsets[i]) > api.config.MaxDeviation {
if yOffsets[i] > 0 {
yOffsets[i] = api.config.MaxDeviation
} else {
yOffsets[i] = -api.config.MaxDeviation
}
}
}
return xOffsets, yOffsets
}
// addNoise 添加随机噪声
func (api *ClickSimulatorAPI) addNoise(maxNoise float64) float64 {
return (api.rand.Float64() - 0.5) * maxNoise * 2
}
// calculateMetrics 计算点击指标
func (api *ClickSimulatorAPI) calculateMetrics(points []ClickPoint, originX, originY float64) ClickMetrics {
if len(points) == 0 {
return ClickMetrics{}
}
totalDuration := points[len(points)-1].Timestamp - points[0].Timestamp
// 计算最大偏移距离
var maxDeviation float64
for _, point := range points {
deviation := math.Sqrt((point.X-originX)*(point.X-originX) + (point.Y-originY)*(point.Y-originY))
if deviation > maxDeviation {
maxDeviation = deviation
}
}
// 计算平均间隔
var averageInterval float64
if len(points) > 1 {
averageInterval = float64(totalDuration) / float64(len(points)-1)
}
return ClickMetrics{
TotalDuration: totalDuration,
PointCount: len(points),
MaxDeviation: maxDeviation,
AverageInterval: averageInterval,
}
}
// ToJSON 将结果转换为JSON
func (resp ClickResponse) ToJSON() (string, error) {
data, err := json.MarshalIndent(resp, "", " ")
if err != nil {
return "", err
}
return string(data), nil
}
// ConvertClickToTouchEvents 将ClickPoint切片转换为TouchEvent切片
func (api *ClickSimulatorAPI) ConvertClickToTouchEvents(points []ClickPoint) []types.TouchEvent {
if len(points) == 0 {
return nil
}
events := make([]types.TouchEvent, len(points))
baseDownTime := points[0].Timestamp
for i, point := range points {
events[i] = types.TouchEvent{
X: point.X,
Y: point.Y,
DeviceID: point.DeviceID,
Pressure: float64(point.Pressure),
Size: point.Size,
RawX: point.X, // 使用相同的X坐标
RawY: point.Y, // 使用相同的Y坐标
DownTime: baseDownTime, // 第一个事件的时间戳作为DownTime
EventTime: point.Timestamp,
ToolType: 1, // TOOL_TYPE_FINGER
Flag: 0, // 默认flag
Action: point.Action, // 直接使用point的action
}
}
return events
}
// GenerateClickEvents 生成点击的TouchEvent序列
func (api *ClickSimulatorAPI) GenerateClickEvents(x, y float64, deviceID int, pressure float64, size float64) ([]types.TouchEvent, error) {
// 验证输入参数
if x < 0 || y < 0 {
return nil, fmt.Errorf("coordinates must be non-negative: x=%.2f, y=%.2f", x, y)
}
// 构建点击请求
req := ClickRequest{
X: x,
Y: y,
DeviceID: deviceID,
Pressure: pressure,
Size: size,
}
// 生成点击轨迹
response := api.GenerateClick(req)
if !response.Success {
return nil, fmt.Errorf("generate click failed: %s", response.Message)
}
// 转换为TouchEvent
events := api.ConvertClickToTouchEvents(response.Points)
return events, nil
}