mirror of
https://github.com/httprunner/httprunner.git
synced 2026-05-13 08:59:44 +08:00
564 lines
15 KiB
Go
564 lines
15 KiB
Go
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
|
||
}
|