Merge branch 'master' into wings_interface_merge

This commit is contained in:
余泓铮
2025-07-30 21:33:55 +08:00
17 changed files with 3349 additions and 116 deletions

View File

@@ -2,10 +2,12 @@ package uitest
import (
"fmt"
"os"
"strconv"
"strings"
"testing"
hrp "github.com/httprunner/httprunner/v5"
"github.com/httprunner/httprunner/v5/uixt"
"github.com/httprunner/httprunner/v5/uixt/option"
"github.com/httprunner/httprunner/v5/uixt/types"
@@ -224,3 +226,379 @@ func TestTouchEventSequenceValidation(t *testing.T) {
t.Logf("Touch sequence validation passed: %d events with correct action sequence", len(events))
}
func TestSwipeWithDirection(t *testing.T) {
device, err := uixt.NewAndroidDevice(
option.WithSerialNumber(""),
)
if err != nil {
t.Fatal(err)
}
driver, err := uixt.NewUIA2Driver(device)
if err != nil {
t.Fatal(err)
}
defer driver.TearDown()
// Test cases for different directions and distance configurations
testCases := []struct {
name string
direction string
startX float64
startY float64
minDistance float64
maxDistance float64
}{
{
name: "随机距离上滑",
direction: "up",
startX: 0.5,
startY: 0.5,
minDistance: 100.0,
maxDistance: 500.0,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := driver.SIMSwipeWithDirection(
tc.direction,
tc.startX,
tc.startY,
tc.minDistance,
tc.maxDistance,
)
if err != nil {
t.Errorf("SwipeWithDirection failed: %v", err)
} else {
t.Logf("Successfully executed swipe: direction=%s, start=(%.1f,%.1f), distance=%.1f-%.1f",
tc.direction, tc.startX, tc.startY, tc.minDistance, tc.maxDistance)
}
})
}
}
func TestSwipeWithDirectionInvalidInputs(t *testing.T) {
device, err := uixt.NewAndroidDevice(
option.WithSerialNumber(""),
)
if err != nil {
t.Fatal(err)
}
driver, err := uixt.NewUIA2Driver(device)
if err != nil {
t.Fatal(err)
}
defer driver.TearDown()
// Test invalid direction
err = driver.SIMSwipeWithDirection("invalid", 500.0, 500.0, 100.0, 200.0)
if err == nil {
t.Error("Expected error for invalid direction, but got none")
}
// Test invalid distance range (max < min)
err = driver.SIMSwipeWithDirection("up", 500.0, 500.0, 200.0, 100.0)
if err == nil {
t.Error("Expected error for invalid distance range, but got none")
}
// Test zero distance
err = driver.SIMSwipeWithDirection("up", 500.0, 500.0, 0.0, 0.0)
if err == nil {
t.Error("Expected error for zero distance, but got none")
}
t.Log("Invalid input validation tests passed")
}
func TestSwipeInArea(t *testing.T) {
device, err := uixt.NewAndroidDevice(
option.WithSerialNumber(""),
)
if err != nil {
t.Fatal(err)
}
driver, err := uixt.NewUIA2Driver(device)
if err != nil {
t.Fatal(err)
}
defer driver.TearDown()
// Test cases for different area configurations and directions
testCases := []struct {
name string
direction string
areaStartX float64
areaStartY float64
areaEndX float64
areaEndY float64
minDistance float64
maxDistance float64
}{
{
name: "中心区域上滑_固定距离",
direction: "up",
areaStartX: 0.2,
areaStartY: 0.3,
areaEndX: 0.8,
areaEndY: 0.6,
minDistance: 500.0,
maxDistance: 700.0, // 固定距离
},
}
for _, tc := range testCases {
for i := 0; i < 3; i++ {
t.Run(tc.name, func(t *testing.T) {
err := driver.SIMSwipeInArea(
tc.direction,
tc.areaStartX,
tc.areaStartY,
tc.areaEndX,
tc.areaEndY,
tc.minDistance,
tc.maxDistance,
)
if err != nil {
t.Errorf("SwipeInArea failed: %v", err)
} else {
t.Logf("Successfully executed area swipe: direction=%s, area=(%.1f,%.1f)-(%.1f,%.1f), distance=%.1f-%.1f",
tc.direction, tc.areaStartX, tc.areaStartY, tc.areaEndX, tc.areaEndY, tc.minDistance, tc.maxDistance)
}
})
}
}
}
func TestSwipeFromPointToPoint(t *testing.T) {
device, err := uixt.NewAndroidDevice(
option.WithSerialNumber(""),
)
if err != nil {
t.Fatal(err)
}
driver, err := uixt.NewUIA2Driver(device)
if err != nil {
t.Fatal(err)
}
defer driver.TearDown()
// Test cases for different point-to-point swipes
testCases := []struct {
name string
startX float64
startY float64
endX float64
endY float64
}{
{
name: "对角线滑动_左上到右下",
startX: 0.2,
startY: 0.3,
endX: 0.8,
endY: 0.5,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := driver.SIMSwipeFromPointToPoint(
tc.startX,
tc.startY,
tc.endX,
tc.endY,
)
if err != nil {
t.Errorf("SwipeFromPointToPoint failed: %v", err)
} else {
t.Logf("Successfully executed point-to-point swipe: %s, from (%.1f,%.1f) to (%.1f,%.1f)",
tc.name, tc.startX, tc.startY, tc.endX, tc.endY)
}
})
}
}
func TestSwipeFromPointToPointInvalidInputs(t *testing.T) {
device, err := uixt.NewAndroidDevice(
option.WithSerialNumber(""),
)
if err != nil {
t.Fatal(err)
}
driver, err := uixt.NewUIA2Driver(device)
if err != nil {
t.Fatal(err)
}
defer driver.TearDown()
// Test same start and end point
err = driver.SIMSwipeFromPointToPoint(0.5, 0.5, 0.5, 0.5)
if err == nil {
t.Error("Expected error for same start and end point, but got none")
}
// Test very close points (should result in distance too short)
err = driver.SIMSwipeFromPointToPoint(0.5, 0.5, 0.501, 0.501)
if err == nil {
t.Error("Expected error for very close points, but got none")
}
t.Log("Point-to-point swipe invalid input validation tests passed")
}
func TestClickAtPoint(t *testing.T) {
device, err := uixt.NewAndroidDevice(
option.WithSerialNumber(""),
)
if err != nil {
t.Fatal(err)
}
driver, err := uixt.NewUIA2Driver(device)
if err != nil {
t.Fatal(err)
}
defer driver.TearDown()
// Test cases for different click positions
testCases := []struct {
name string
x float64
y float64
}{
{
name: "屏幕中心点击",
x: 0.5,
y: 0.5,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := driver.SIMClickAtPoint(tc.x, tc.y)
if err != nil {
t.Errorf("ClickAtPoint failed: %v", err)
} else {
t.Logf("Successfully executed click: %s at (%.1f, %.1f)",
tc.name, tc.x, tc.y)
}
})
}
}
func TestClickAtPointInvalidInputs(t *testing.T) {
device, err := uixt.NewAndroidDevice(
option.WithSerialNumber(""),
)
if err != nil {
t.Fatal(err)
}
driver, err := uixt.NewUIA2Driver(device)
if err != nil {
t.Fatal(err)
}
defer driver.TearDown()
// Test negative coordinates
err = driver.SIMClickAtPoint(-0.1, 0.5)
if err == nil {
t.Error("Expected error for negative x coordinate, but got none")
}
err = driver.SIMClickAtPoint(0.5, -0.1)
if err == nil {
t.Error("Expected error for negative y coordinate, but got none")
}
// Test coordinates out of range (though these should be handled by convertToAbsolutePoint)
err = driver.SIMClickAtPoint(1.5, 0.5)
if err != nil {
t.Logf("Out of range coordinates handled properly: %v", err)
}
t.Log("Click invalid input validation tests passed")
}
func TestSIMInput(t *testing.T) {
device, err := uixt.NewAndroidDevice(
option.WithSerialNumber(""),
)
if err != nil {
t.Fatal(err)
}
driver, err := uixt.NewUIA2Driver(device)
if err != nil {
t.Fatal(err)
}
defer driver.TearDown()
// Test cases for different text inputs
testCases := []struct {
name string
text string
}{
{
name: "长文本",
text: "This is a very long text to test the performance of SIMInput function. 这是一个很长的文本用来测试SIMInput函数的性能。1234567890!@#$%^&*()英語の長い文字",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := driver.SIMInput(tc.text)
// err := driver.Input(tc.text)
if err != nil {
t.Errorf("SIMInput failed: %v", err)
} else {
t.Logf("Successfully executed SIMInput: %s with text '%s'", tc.name, tc.text)
}
})
}
}
// TestStepMultipleSIMActions tests multiple SIM actions in one test case
func TestStepMultipleSIMActions(t *testing.T) {
// 创建包含多个SIM操作的测试用例
testCase := &hrp.TestCase{
Config: hrp.NewConfig("多个SIM操作组合测试").SetAndroid(option.WithUIA2(true), option.WithSerialNumber("")),
TestSteps: []hrp.IStep{
hrp.NewStep("组合SIM操作测试").
Android().
SIMClickAtPoint(0.5, 0.5). // 点击屏幕中心
Sleep(1). // 等待1秒
SIMSwipeWithDirection("up", 0.5, 0.7, 200.0, 400.0). // 向上滑动
Sleep(0.5). // 等待0.5秒
SIMSwipeInArea("up", 0.2, 0.2, 0.6, 0.6, 350.0, 500.0). // 在区域内向下滑动
Sleep(0.5). // 等待0.5秒
SIMSwipeFromPointToPoint(0.1, 0.5, 0.9, 0.5). // 从左到右滑动
Sleep(0.5). // 等待0.5秒
SIMInput("测试组合操作 Test Combination 123"), // 仿真输入
},
}
// 运行测试用例
err := testCase.Dump2JSON("TestStepMultipleSIMActions.json")
if err != nil {
t.Fatalf("Failed to dump test case: %v", err)
}
defer func() {
// 清理生成的文件
_ = os.Remove("TestStepMultipleSIMActions.json")
}()
// 执行测试用例
err = hrp.NewRunner(t).Run(testCase)
if err != nil {
t.Errorf("Test case failed: %v", err)
}
t.Logf("Successfully executed multiple SIM actions test")
}

View File

@@ -64,7 +64,7 @@ func NewGA4Client(measurementID, apiSecret string, debug ...bool) *GA4Client {
apiSecret: apiSecret,
userID: userID,
httpClient: &http.Client{
Timeout: 5 * time.Second,
Timeout: 10 * time.Second,
},
debug: dbg,
}
@@ -206,6 +206,6 @@ func SendGA4Event(name string, params map[string]interface{}) {
}
err := ga4Client.SendEvent(event)
if err != nil {
log.Warn().Err(err).Msg("send GA4 event failed")
log.Debug().Err(err).Msg("send GA4 event failed")
}
}

View File

@@ -0,0 +1,563 @@
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
}

View File

@@ -0,0 +1,152 @@
package simulation
import (
"math/rand"
"time"
)
type DeviceConfig struct {
DeviceID int
PressureMin float64
PressureMax float64
SizeMin float64
SizeMax float64
}
// DeviceParams 设备参数结构体
type DeviceParams struct {
DeviceID int
Pressure float64
Size float64
}
// GetRandomDeviceParams 根据设备型号获取随机的设备参数
func GetRandomDeviceParams(deviceModel string) DeviceParams {
config := getDeviceConfig(deviceModel)
// 创建随机数生成器
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
// 在最小值和最大值之间生成随机数
randomPressure := config.PressureMin + rng.Float64()*(config.PressureMax-config.PressureMin)
randomSize := config.SizeMin + rng.Float64()*(config.SizeMax-config.SizeMin)
// 保留合理的精度
randomPressure = float64(int(randomPressure*100)) / 100 // 保留2位小数
randomSize = float64(int(randomSize*1000)) / 1000 // 保留3位小数
return DeviceParams{
DeviceID: config.DeviceID,
Pressure: randomPressure,
Size: randomSize,
}
}
// getDeviceConfig returns device-specific configuration based on device model
func getDeviceConfig(deviceModel string) DeviceConfig {
switch deviceModel {
// "HUAWEI"
case "SEA-AL00": // 华为nova5
return DeviceConfig{
DeviceID: 1,
PressureMin: 1.2,
PressureMax: 1.8,
SizeMin: 160.0,
SizeMax: 200.0,
}
case "ABR-AL00": // 华为P50
return DeviceConfig{
DeviceID: 3,
PressureMin: 1.4,
PressureMax: 2.0,
SizeMin: 170.0,
SizeMax: 220.0,
}
case "SEA-AL10": // 华为nova5Pro
return DeviceConfig{
DeviceID: 3,
PressureMin: 1.3,
PressureMax: 1.9,
SizeMin: 165.0,
SizeMax: 210.0,
}
case "ANA-AN00": // 华为P40
return DeviceConfig{
DeviceID: 4,
PressureMin: 1.5,
PressureMax: 2.2,
SizeMin: 180.0,
SizeMax: 230.0,
}
case "ELS-AN00": // 华为P40Pro
return DeviceConfig{
DeviceID: 5,
PressureMin: 1.6,
PressureMax: 2.3,
SizeMin: 185.0,
SizeMax: 240.0,
}
case "NCO_AL00":
return DeviceConfig{
DeviceID: 3,
PressureMin: 3,
PressureMax: 7,
SizeMin: 140.0,
SizeMax: 200.0,
}
// "Xiaomi"
case "M2007J22C": // RedmiNote9 5G
return DeviceConfig{
DeviceID: 3,
PressureMin: 1.3,
PressureMax: 1.9,
SizeMin: 170.0,
SizeMax: 215.0,
}
case "2211133C": // 小米13
return DeviceConfig{
DeviceID: 7,
PressureMin: 1.7,
PressureMax: 2.4,
SizeMin: 190.0,
SizeMax: 250.0,
}
case "2206123SC": // 小米12s
return DeviceConfig{
DeviceID: 8,
PressureMin: 1.6,
PressureMax: 2.3,
SizeMin: 185.0,
SizeMax: 245.0,
}
case "21091116C":
return DeviceConfig{
DeviceID: 5,
PressureMin: 1,
PressureMax: 1,
SizeMin: 0,
SizeMax: 1,
}
// "Google"
case "Pixel 6 Pro":
return DeviceConfig{
DeviceID: 4,
PressureMin: 1.4,
PressureMax: 2.1,
SizeMin: 175.0,
SizeMax: 225.0,
}
// Default configuration for unknown devices
default:
return DeviceConfig{
DeviceID: 6,
PressureMin: 1.2,
PressureMax: 2.0,
SizeMin: 160.0,
SizeMax: 220.0,
}
}
}

View File

@@ -0,0 +1,287 @@
package simulation
import (
"math/rand"
"time"
"unicode"
)
// InputRequest 输入请求参数
type InputRequest struct {
Text string `json:"text"` // 输入文本
MinSegmentLen int `json:"min_segment"` // 最小分割长度
MaxSegmentLen int `json:"max_segment"` // 最大分割长度
MinDelayMs int `json:"min_delay_ms"` // 最小延迟时间(毫秒)
MaxDelayMs int `json:"max_delay_ms"` // 最大延迟时间(毫秒)
}
// InputResponse 输入响应结果
type InputResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Segments []InputSegment `json:"segments"`
Metrics InputMetrics `json:"metrics"`
}
// InputSegment 输入片段
type InputSegment struct {
Index int `json:"index"` // 片段索引
Text string `json:"text"` // 片段文本
DelayMs int `json:"delay_ms"` // 该片段后的延迟时间(毫秒)
CharLen int `json:"char_len"` // 字符长度
}
// InputMetrics 输入指标
type InputMetrics struct {
TotalSegments int `json:"total_segments"` // 总片段数
TotalDelayMs int `json:"total_delay_ms"` // 总延迟时间
EstimatedTimeMs int `json:"estimated_time_ms"` // 预估总耗时
OriginalCharLen int `json:"original_char_len"` // 原始字符长度
}
// InputConfig 输入配置参数
type InputConfig struct {
MinSegmentLen int // 最小分割长度(字符数)
MaxSegmentLen int // 最大分割长度(字符数)
MinDelayMs int // 最小延迟时间(毫秒)
MaxDelayMs int // 最大延迟时间(毫秒)
}
// DefaultInputConfig 默认输入配置
var DefaultInputConfig = InputConfig{
MinSegmentLen: 1, // 1个字符
MaxSegmentLen: 4, // 4个字符
MinDelayMs: 50, // 50毫秒
MaxDelayMs: 200, // 200毫秒
}
// InputSimulatorAPI 输入仿真API
type InputSimulatorAPI struct {
rand *rand.Rand
config InputConfig
}
// NewInputSimulatorAPI 创建新的输入仿真API
func NewInputSimulatorAPI(config *InputConfig) *InputSimulatorAPI {
if config == nil {
config = &DefaultInputConfig
}
return &InputSimulatorAPI{
rand: rand.New(rand.NewSource(time.Now().UnixNano())),
config: *config,
}
}
// GenerateInputSegments 生成输入片段序列
func (api *InputSimulatorAPI) GenerateInputSegments(req InputRequest) InputResponse {
// 验证输入参数
if err := api.validateRequest(req); err != nil {
return InputResponse{
Success: false,
Message: err.Error(),
}
}
// 如果文本为空,直接返回
if req.Text == "" {
return InputResponse{
Success: true,
Segments: []InputSegment{},
Metrics: InputMetrics{
TotalSegments: 0,
TotalDelayMs: 0,
EstimatedTimeMs: 0,
OriginalCharLen: 0,
},
}
}
// 生成分割片段
segments := api.splitTextIntelligently(req.Text, req.MinSegmentLen, req.MaxSegmentLen)
// 生成延迟时间
inputSegments := make([]InputSegment, len(segments))
totalDelayMs := 0
for i, segment := range segments {
var delayMs int
// 最后一个片段不需要延迟
if i < len(segments)-1 {
delayMs = api.generateRandomDelay(req.MinDelayMs, req.MaxDelayMs)
totalDelayMs += delayMs
}
inputSegments[i] = InputSegment{
Index: i,
Text: segment,
DelayMs: delayMs,
CharLen: len([]rune(segment)),
}
}
// 计算指标
metrics := InputMetrics{
TotalSegments: len(segments),
TotalDelayMs: totalDelayMs,
EstimatedTimeMs: totalDelayMs, // 简化计算,实际输入时间可能更长
OriginalCharLen: len([]rune(req.Text)),
}
return InputResponse{
Success: true,
Segments: inputSegments,
Metrics: metrics,
}
}
// validateRequest 验证请求参数
func (api *InputSimulatorAPI) validateRequest(req InputRequest) error {
// 使用配置中的默认值填充请求参数
if req.MinSegmentLen <= 0 {
req.MinSegmentLen = api.config.MinSegmentLen
}
if req.MaxSegmentLen <= 0 {
req.MaxSegmentLen = api.config.MaxSegmentLen
}
if req.MinDelayMs < 0 {
req.MinDelayMs = api.config.MinDelayMs
}
if req.MaxDelayMs < 0 {
req.MaxDelayMs = api.config.MaxDelayMs
}
return nil
}
// splitTextIntelligently 智能分割文本
// 规则:
// 1. 先分解成基础单元:中文每个字符一个单元,英文/数字连续的作为一个单元,其他字符各自一个单元
// 2. 按MinSegmentLen到MaxSegmentLen的随机值组合基础单元
func (api *InputSimulatorAPI) splitTextIntelligently(text string, minLen, maxLen int) []string {
if minLen <= 0 {
minLen = api.config.MinSegmentLen
}
if maxLen <= 0 {
maxLen = api.config.MaxSegmentLen
}
if maxLen < minLen {
maxLen = minLen
}
// 第一步:分解成基础单元
baseUnits := api.splitIntoBaseUnits(text)
// 第二步:按随机数组合基础单元
var segments []string
i := 0
for i < len(baseUnits) {
remainingUnits := len(baseUnits) - i
var unitCount int
// 如果剩余单元数少于minLen就把剩余的全部作为一个片段
if remainingUnits < minLen {
unitCount = remainingUnits
} else {
// 随机决定本次要组合的单元数量在minLen到maxLen之间
unitCount = minLen
if maxLen > minLen {
// 确保unitCount不超过剩余单元数
maxPossibleCount := maxLen
if maxPossibleCount > remainingUnits {
maxPossibleCount = remainingUnits
}
unitCount = minLen + api.rand.Intn(maxPossibleCount-minLen+1)
}
}
// 组合unitCount个基础单元成一个片段
segment := ""
for j := 0; j < unitCount; j++ {
segment += baseUnits[i+j]
}
segments = append(segments, segment)
i += unitCount
}
return segments
}
// splitIntoBaseUnits 将文本分解成基础单元
func (api *InputSimulatorAPI) splitIntoBaseUnits(text string) []string {
var units []string
runes := []rune(text)
i := 0
for i < len(runes) {
// 处理中文字符:每个字符一个单元
if api.isChinese(runes[i]) {
units = append(units, string(runes[i]))
i++
continue
}
// 处理连续英文字母:作为一个单元
if unicode.IsLetter(runes[i]) && runes[i] <= 127 {
start := i
for i < len(runes) && unicode.IsLetter(runes[i]) && runes[i] <= 127 {
i++
}
word := string(runes[start:i])
units = append(units, word)
continue
}
// 处理连续数字:作为一个单元
if unicode.IsDigit(runes[i]) {
start := i
for i < len(runes) && unicode.IsDigit(runes[i]) {
i++
}
number := string(runes[start:i])
units = append(units, number)
continue
}
// 处理其他字符(空格、标点等):每个字符一个单元
units = append(units, string(runes[i]))
i++
}
return units
}
// isChinese 判断字符是否为中文
func (api *InputSimulatorAPI) isChinese(r rune) bool {
return unicode.Is(unicode.Scripts["Han"], r)
}
// generateRandomDelay 生成随机延迟时间
func (api *InputSimulatorAPI) generateRandomDelay(minDelayMs, maxDelayMs int) int {
if minDelayMs < 0 {
minDelayMs = api.config.MinDelayMs
}
if maxDelayMs < 0 {
maxDelayMs = api.config.MaxDelayMs
}
if maxDelayMs < minDelayMs {
maxDelayMs = minDelayMs
}
if maxDelayMs == minDelayMs {
return minDelayMs
}
return minDelayMs + api.rand.Intn(maxDelayMs-minDelayMs+1)
}
// SplitText 公开的文本分割函数(使用智能分割)
func (api *InputSimulatorAPI) SplitText(text string) []string {
return api.splitTextIntelligently(text, api.config.MinSegmentLen, api.config.MaxSegmentLen)
}
// GenerateDelay 公开的延迟生成函数
func (api *InputSimulatorAPI) GenerateDelay() int {
return api.generateRandomDelay(api.config.MinDelayMs, api.config.MaxDelayMs)
}

View File

@@ -0,0 +1,956 @@
package simulation
import (
"encoding/json"
"fmt"
"math"
"math/rand"
"time"
"github.com/httprunner/httprunner/v5/uixt/types"
)
// SlideRequest 滑动请求参数
type SlideRequest struct {
StartX float64 `json:"start_x"` // 起始X坐标
StartY float64 `json:"start_y"` // 起始Y坐标
Direction Direction `json:"direction"` // 滑动方向
Distance float64 `json:"distance"` // 滑动距离
DeviceID int `json:"device_id"` // 设备ID
Pressure float64 `json:"pressure"` // 压力值
Size float64 `json:"size"` // 按压大小(接触面积)
}
// PointToPointSlideRequest 点对点滑动请求参数
type PointToPointSlideRequest struct {
StartX float64 `json:"start_x"` // 起始X坐标
StartY float64 `json:"start_y"` // 起始Y坐标
EndX float64 `json:"end_x"` // 结束X坐标
EndY float64 `json:"end_y"` // 结束Y坐标
DeviceID int `json:"device_id"` // 设备ID
Pressure float64 `json:"pressure"` // 压力值
Size float64 `json:"size"` // 按压大小(接触面积)
}
// SlideResponse 滑动响应结果
type SlideResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Points []SlidePoint `json:"points"`
Metrics SlideMetrics `json:"metrics"`
}
// SlideMetrics 滑动指标
type SlideMetrics struct {
TotalDuration int64 `json:"total_duration_ms"` // 总持续时间(毫秒)
PointCount int `json:"point_count"` // 轨迹点数量
ActualDistance float64 `json:"actual_distance"` // 实际滑动距离
AverageInterval float64 `json:"average_interval_ms"` // 平均采样间隔
}
// SlidePoint 滑动轨迹点
type SlidePoint 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"` // 按压大小(接触面积)
EventTime int64 `json:"event_time"` // 相对第一个点的时间(ms)第一个点为0
}
// Direction 滑动方向枚举
type Direction string
const (
Up Direction = "up"
Down Direction = "down"
Left Direction = "left"
Right Direction = "right"
)
// SlideConfig 滑动配置参数
type SlideConfig struct {
MinDuration int64 // 最小持续时间(毫秒)
MaxDuration int64 // 最大持续时间(毫秒)
MinPoints int // 最小点数
MaxPoints int // 最大点数
CurveIntensity float64 // 曲线强度(0-1)
NoiseLevel float64 // 噪声级别
}
// DefaultSlideConfig 默认配置
var DefaultSlideConfig = SlideConfig{
MinDuration: 80,
MaxDuration: 200,
MinPoints: 4,
MaxPoints: 8,
CurveIntensity: 0.05,
NoiseLevel: 2.0,
}
// SlideSimulatorAPI 滑动仿真API
type SlideSimulatorAPI struct {
rand *rand.Rand
config SlideConfig
}
// TestCase 测试用例
type TestCase struct {
Name string
StartX float64
StartY float64
Direction Direction
Distance float64
DeviceID int
Pressure float64
Size float64
}
// NewSlideSimulatorAPI 创建新的滑动仿真API
func NewSlideSimulatorAPI(config *SlideConfig) *SlideSimulatorAPI {
if config == nil {
config = &DefaultSlideConfig
}
return &SlideSimulatorAPI{
rand: rand.New(rand.NewSource(time.Now().UnixNano())),
config: *config,
}
}
// GenerateSlide 生成滑动轨迹
func (api *SlideSimulatorAPI) GenerateSlide(req SlideRequest) SlideResponse {
// 验证输入参数
if err := api.validateRequest(req); err != nil {
return SlideResponse{
Success: false,
Message: err.Error(),
}
}
// 生成滑动轨迹
points := api.generateSlidePoints(req)
// 计算指标
metrics := api.calculateMetrics(points)
return SlideResponse{
Success: true,
Points: points,
Metrics: metrics,
}
}
// pressureRiseCurve 压力上升曲线,模拟真实的压力变化
func (api *SlideSimulatorAPI) pressureRiseCurve(t float64) float64 {
// 使用二次函数模拟压力逐渐增加的过程
return t*t*0.6 + t*0.4
}
// pressureFallCurve 压力下降曲线,模拟真实的压力变化
func (api *SlideSimulatorAPI) pressureFallCurve(t float64) float64 {
// 使用指数衰减模拟压力快速下降的过程
return 1.0 - (1.0-math.Exp(-t*2.0))*0.8
}
// GeneratePointToPointSlide 生成点对点滑动轨迹
func (api *SlideSimulatorAPI) GeneratePointToPointSlide(req PointToPointSlideRequest) SlideResponse {
// 验证输入参数
if err := api.validatePointToPointRequest(req); err != nil {
return SlideResponse{
Success: false,
Message: err.Error(),
}
}
// 生成滑动轨迹
points := api.generatePointToPointSlidePoints(req)
// 计算指标
metrics := api.calculateMetrics(points)
return SlideResponse{
Success: true,
Points: points,
Metrics: metrics,
}
}
// validateRequest 验证请求参数
func (api *SlideSimulatorAPI) validateRequest(req SlideRequest) error {
if req.Distance <= 0 {
return fmt.Errorf("distance must be positive")
}
switch req.Direction {
case Up, Down, Left, Right:
// 有效方向
default:
return fmt.Errorf("invalid direction: %s", req.Direction)
}
return nil
}
// validatePointToPointRequest 验证点对点请求参数
func (api *SlideSimulatorAPI) validatePointToPointRequest(req PointToPointSlideRequest) error {
// 检查起始点和结束点是否相同
if req.StartX == req.EndX && req.StartY == req.EndY {
return fmt.Errorf("start point and end point cannot be the same")
}
// 检查距离是否合理
distance := math.Sqrt((req.EndX-req.StartX)*(req.EndX-req.StartX) + (req.EndY-req.StartY)*(req.EndY-req.StartY))
if distance < 10 {
return fmt.Errorf("distance too short: %.2f pixels", distance)
}
return nil
}
// generateSlidePoints 生成滑动轨迹点
func (api *SlideSimulatorAPI) generateSlidePoints(req SlideRequest) []SlidePoint {
// 计算终点坐标
endX, endY := api.calculateEndPoint(req.StartX, req.StartY, req.Direction, req.Distance)
// 计算滑动参数
duration := api.calculateDuration(req.Distance)
pointCount := api.calculatePointCount(duration)
// 生成时间戳序列
timestamps := api.generateTimestamps(duration, pointCount)
// 生成轨迹点
points := make([]SlidePoint, pointCount)
// 计算总偏移趋势(基于真实数据分析)
var totalOffsetX, totalOffsetY float64
switch req.Direction {
case Up:
// 上滑时倾向于向右偏移偏移量为距离的15%-35%
offsetRatio := 0.15 + api.rand.Float64()*0.20
totalOffsetX = req.Distance * offsetRatio
totalOffsetY = 0
case Down:
// 下滑时可以左右偏移,但偏移较小
offsetRatio := 0.10 + api.rand.Float64()*0.15
totalOffsetX = (api.rand.Float64() - 0.5) * req.Distance * offsetRatio
totalOffsetY = 0
case Left:
// 左滑时可能向上或向下偏移
offsetRatio := 0.05 + api.rand.Float64()*0.20
totalOffsetX = 0
totalOffsetY = (api.rand.Float64() - 0.5) * req.Distance * offsetRatio
case Right:
// 右滑时偏移相对较小
offsetRatio := 0.03 + api.rand.Float64()*0.10
totalOffsetX = 0
totalOffsetY = (api.rand.Float64() - 0.5) * req.Distance * offsetRatio
}
// 生成size变化曲线基于真实数据分析
sizeValues := api.generateSizeValues(pointCount, req.Size)
// 生成pressure变化曲线基于真实数据分析
pressureValues := api.generatePressureValues(pointCount, req.Pressure, req.Direction)
baseTimestamp := timestamps[0]
for i := 0; i < pointCount; i++ {
progress := float64(i) / float64(pointCount-1)
// 使用贝塞尔曲线生成基础轨迹
x, y := api.calculateBezierPoint(req.StartX, req.StartY, endX, endY, progress, req.Direction)
// 添加渐进式偏移(模拟真实滑动的累积偏移)
progressiveOffsetX := totalOffsetX * api.getProgressiveOffset(progress)
progressiveOffsetY := totalOffsetY * api.getProgressiveOffset(progress)
x += progressiveOffsetX
y += progressiveOffsetY
// 添加随机噪声(减小噪声强度,因为主要偏移已经通过渐进式偏移实现)
x += api.addNoise(api.config.NoiseLevel * 0.5)
y += api.addNoise(api.config.NoiseLevel * 0.5)
eventTime := timestamps[i] - baseTimestamp
points[i] = SlidePoint{
Timestamp: timestamps[i],
X: x,
Y: y,
DeviceID: req.DeviceID,
Pressure: pressureValues[i],
Size: sizeValues[i],
EventTime: eventTime,
}
}
return points
}
// generatePointToPointSlidePoints 生成点对点滑动轨迹点
func (api *SlideSimulatorAPI) generatePointToPointSlidePoints(req PointToPointSlideRequest) []SlidePoint {
// 对起始点和结束点添加随机偏移正负20以内
offsetRange := 20.0
actualStartX := req.StartX + api.addNoise(offsetRange)
actualStartY := req.StartY + api.addNoise(offsetRange)
actualEndX := req.EndX + api.addNoise(offsetRange)
actualEndY := req.EndY + api.addNoise(offsetRange)
// 计算实际距离
distance := math.Sqrt((actualEndX-actualStartX)*(actualEndX-actualStartX) + (actualEndY-actualStartY)*(actualEndY-actualStartY))
// 计算滑动参数
duration := api.calculateDuration(distance)
pointCount := api.calculatePointCount(duration)
// 生成时间戳序列
timestamps := api.generateTimestamps(duration, pointCount)
// 生成轨迹点
points := make([]SlidePoint, pointCount)
// 判断主要滑动方向,用于计算偏移
dx := actualEndX - actualStartX
dy := actualEndY - actualStartY
var direction Direction
if math.Abs(dy) > math.Abs(dx) {
if dy < 0 {
direction = Up
} else {
direction = Down
}
} else {
if dx < 0 {
direction = Left
} else {
direction = Right
}
}
// 计算总偏移趋势(基于主要方向)
var totalOffsetX, totalOffsetY float64
switch direction {
case Up:
// 上滑时倾向于向右偏移
offsetRatio := 0.10 + api.rand.Float64()*0.15
totalOffsetX = distance * offsetRatio
totalOffsetY = 0
case Down:
// 下滑时可以左右偏移,但偏移较小
offsetRatio := 0.05 + api.rand.Float64()*0.10
totalOffsetX = (api.rand.Float64() - 0.5) * distance * offsetRatio
totalOffsetY = 0
case Left:
// 左滑时可能向上或向下偏移
offsetRatio := 0.03 + api.rand.Float64()*0.15
totalOffsetX = 0
totalOffsetY = (api.rand.Float64() - 0.5) * distance * offsetRatio
case Right:
// 右滑时偏移相对较小
offsetRatio := 0.02 + api.rand.Float64()*0.08
totalOffsetX = 0
totalOffsetY = (api.rand.Float64() - 0.5) * distance * offsetRatio
}
// 生成size变化曲线
sizeValues := api.generateSizeValues(pointCount, req.Size)
// 生成pressure变化曲线
pressureValues := api.generatePressureValues(pointCount, req.Pressure, direction)
baseTimestamp := timestamps[0]
for i := 0; i < pointCount; i++ {
progress := float64(i) / float64(pointCount-1)
// 使用贝塞尔曲线生成基础轨迹
x, y := api.calculateBezierPoint(actualStartX, actualStartY, actualEndX, actualEndY, progress, direction)
// 添加渐进式偏移
progressiveOffsetX := totalOffsetX * api.getProgressiveOffset(progress)
progressiveOffsetY := totalOffsetY * api.getProgressiveOffset(progress)
x += progressiveOffsetX
y += progressiveOffsetY
// 添加随机噪声
x += api.addNoise(api.config.NoiseLevel * 0.5)
y += api.addNoise(api.config.NoiseLevel * 0.5)
eventTime := timestamps[i] - baseTimestamp
points[i] = SlidePoint{
Timestamp: timestamps[i],
X: x,
Y: y,
DeviceID: req.DeviceID,
Pressure: pressureValues[i],
Size: sizeValues[i],
EventTime: eventTime,
}
}
return points
}
// generateSizeValues 生成size值序列基于真实数据分析
func (api *SlideSimulatorAPI) generateSizeValues(pointCount int, baseSize float64) []float64 {
sizes := make([]float64, pointCount)
// 如果baseSize为0使用默认值
if baseSize == 0 {
baseSize = 0.04 // 默认size值基于真实数据平均值
}
// 动态计算size范围基于baseSize的值来适应不同设备
var minSize, maxSize float64
if baseSize < 1.0 {
// 小数值范围如0.04),使用原有逻辑
minSize = 0.031
maxSize = 0.063
// 确保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.8 + api.rand.Float64()*0.4 // 0.8-1.2倍
} else if i == pointCount-1 {
// 结束时:可能增大(手指离开前压力增加)
if api.rand.Float64() < 0.6 { // 60%概率增大
sizeModifier = 1.1 + api.rand.Float64()*0.3 // 1.1-1.4倍
} else {
sizeModifier = 0.9 + api.rand.Float64()*0.2 // 0.9-1.1倍
}
} else {
// 中间过程:轻微波动
sizeModifier = 0.85 + api.rand.Float64()*0.3 // 0.85-1.15倍
}
// 应用变化
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.003 // 小数值使用固定的小噪声
} 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
}
// generatePressureValues 生成pressure值序列基于用户输入的压力值动态仿真
func (api *SlideSimulatorAPI) generatePressureValues(pointCount int, basePressure float64, direction Direction) []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)
// 基于真实数据观察的压力变化规律:
// 1. 起始压力基础压力的70%-90%
// 2. 峰值压力基础压力的120%-180%
// 3. 结束压力基础压力的30%-60%
startPressureRatio := 0.7 + api.rand.Float64()*0.2 // 70%-90%
peakPressureRatio := 1.2 + api.rand.Float64()*0.6 // 120%-180%
endPressureRatio := 0.3 + api.rand.Float64()*0.3 // 30%-60%
startPressure := baseP * startPressureRatio
peakPressure := baseP * peakPressureRatio
endPressure := baseP * endPressureRatio
// 峰值出现的位置通常在滑动过程的20%-70%处
peakPosition := 0.2 + api.rand.Float64()*0.5
peakIndex := int(float64(pointCount-1) * peakPosition)
if peakIndex >= pointCount {
peakIndex = pointCount - 1
}
// 确保压力值在合理范围内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 <= peakIndex {
// 上升阶段:从起始到峰值
if peakIndex == 0 {
pressure = startPressure
} else {
t := float64(i) / float64(peakIndex)
// 使用非线性插值,模拟真实的压力上升曲线
t = api.pressureRiseCurve(t)
pressure = startPressure + (peakPressure-startPressure)*t
}
} else {
// 下降阶段:从峰值到结束
t := float64(i-peakIndex) / float64(pointCount-1-peakIndex)
// 使用非线性插值,模拟真实的压力下降曲线
t = api.pressureFallCurve(t)
pressure = peakPressure + (endPressure-peakPressure)*t
}
// 添加随机噪声±8%),模拟真实手指压力的微小波动
noiseRange := pressure * 0.08
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
// 对于最后一个点,可能会有重复(基于真实数据观察)
if i == pointCount-1 && api.rand.Float64() < 0.25 {
// 25%概率最后一个点重复前一个点的压力值
if i > 0 {
pressures[i] = pressures[i-1]
}
}
}
return pressures
}
// getProgressiveOffset 获取渐进式偏移系数
func (api *SlideSimulatorAPI) getProgressiveOffset(progress float64) float64 {
// 使用二次函数让偏移逐渐增加,模拟真实滑动中的累积偏移
// 开始时偏移较小,中后期偏移逐渐增大
return progress*progress*0.7 + progress*0.3
}
// calculateEndPoint 计算终点坐标
func (api *SlideSimulatorAPI) calculateEndPoint(startX, startY float64, direction Direction, distance float64) (float64, float64) {
switch direction {
case Up:
return startX, startY - distance
case Down:
return startX, startY + distance
case Left:
return startX - distance, startY
case Right:
return startX + distance, startY
default:
return startX, startY
}
}
// calculateDuration 计算滑动持续时间
func (api *SlideSimulatorAPI) calculateDuration(distance float64) int64 {
// 基于真实数据的持续时间算法
baseDuration := 120.0
variableDuration := distance * 0.05
randomFactor := api.rand.Float64()*40 - 20
duration := baseDuration + variableDuration + 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 *SlideSimulatorAPI) calculatePointCount(duration int64) int {
avgInterval := 20.0 + api.rand.Float64()*10
count := int(float64(duration)/avgInterval) + 1
if count < api.config.MinPoints {
count = api.config.MinPoints
}
if count > api.config.MaxPoints {
count = api.config.MaxPoints
}
return count
}
// generateTimestamps 生成时间戳序列
func (api *SlideSimulatorAPI) generateTimestamps(duration int64, pointCount int) []int64 {
baseTime := time.Now().UnixMilli()
timestamps := make([]int64, pointCount)
timestamps[0] = baseTime
for i := 1; i < pointCount; i++ {
progress := float64(i) / float64(pointCount-1)
timeProgress := api.speedCurve(progress)
timestamps[i] = baseTime + int64(timeProgress*float64(duration))
}
return timestamps
}
// speedCurve 速度曲线函数
func (api *SlideSimulatorAPI) speedCurve(progress float64) float64 {
// 模拟真实滑动的速度变化
if progress <= 0.5 {
return 0.8*progress*progress + 0.2*progress
} else {
return 0.2 + 0.8*(2*progress-1)
}
}
// calculateBezierPoint 计算贝塞尔曲线点
func (api *SlideSimulatorAPI) calculateBezierPoint(startX, startY, endX, endY, progress float64, direction Direction) (float64, float64) {
controlX, controlY := api.calculateControlPoint(startX, startY, endX, endY, direction)
t := progress
oneMinusT := 1 - t
x := oneMinusT*oneMinusT*startX + 2*oneMinusT*t*controlX + t*t*endX
y := oneMinusT*oneMinusT*startY + 2*oneMinusT*t*controlY + t*t*endY
return x, y
}
// calculateControlPoint 计算控制点
func (api *SlideSimulatorAPI) calculateControlPoint(startX, startY, endX, endY float64, direction Direction) (float64, float64) {
midX := (startX + endX) / 2
midY := (startY + endY) / 2
distance := math.Sqrt((endX-startX)*(endX-startX) + (endY-startY)*(endY-startY))
var offsetX, offsetY float64
switch direction {
case Up, Down:
// 垂直滑动时的X轴偏移根据真实数据分析平均偏移比例为25.8%
// 偏移范围距离的15%-35%
offsetRatio := 0.15 + api.rand.Float64()*0.20 // 15%-35%
maxOffsetX := distance * offsetRatio
// 上滑时倾向于向右偏移,下滑时可以任意方向
if direction == Up {
offsetX = api.rand.Float64() * maxOffsetX // 0到最大偏移向右
} else {
offsetX = (api.rand.Float64() - 0.5) * maxOffsetX // 左右偏移
}
offsetY = 0
case Left, Right:
// 水平滑动时的Y轴偏移根据真实数据分析平均偏移比例为12.5%
// 偏移范围距离的5%-25%
offsetRatio := 0.05 + api.rand.Float64()*0.20 // 5%-25%
maxOffsetY := distance * offsetRatio
offsetX = 0
// 左滑时可能向上或向下偏移,右滑时偏移较小
if direction == Left {
offsetY = (api.rand.Float64() - 0.5) * maxOffsetY
} else {
// 右滑时偏移相对较小
offsetY = (api.rand.Float64() - 0.5) * maxOffsetY * 0.7
}
}
return midX + offsetX, midY + offsetY
}
// addNoise 添加随机噪声
func (api *SlideSimulatorAPI) addNoise(maxNoise float64) float64 {
return (api.rand.Float64() - 0.5) * maxNoise
}
// calculateMetrics 计算滑动指标
func (api *SlideSimulatorAPI) calculateMetrics(points []SlidePoint) SlideMetrics {
if len(points) == 0 {
return SlideMetrics{}
}
totalDuration := points[len(points)-1].Timestamp - points[0].Timestamp
// 计算实际距离
var actualDistance float64
for i := 1; i < len(points); i++ {
dx := points[i].X - points[i-1].X
dy := points[i].Y - points[i-1].Y
actualDistance += math.Sqrt(dx*dx + dy*dy)
}
// 计算平均间隔
var averageInterval float64
if len(points) > 1 {
averageInterval = float64(totalDuration) / float64(len(points)-1)
}
return SlideMetrics{
TotalDuration: totalDuration,
PointCount: len(points),
ActualDistance: actualDistance,
AverageInterval: averageInterval,
}
}
// ToJSON 将结果转换为JSON
func (resp SlideResponse) ToJSON() (string, error) {
data, err := json.MarshalIndent(resp, "", " ")
if err != nil {
return "", err
}
return string(data), nil
}
// ConvertToTouchEvents 将SlidePoint切片转换为TouchEvent切片
func (api *SlideSimulatorAPI) ConvertToTouchEvents(points []SlidePoint) []types.TouchEvent {
if len(points) == 0 {
return nil
}
events := make([]types.TouchEvent, len(points))
baseDownTime := points[0].Timestamp
for i, point := range points {
var action int
if i == 0 {
action = 0 // ACTION_DOWN
} else if i == len(points)-1 {
action = 1 // ACTION_UP
} else {
action = 2 // ACTION_MOVE
}
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: action,
}
}
return events
}
// GenerateSlideWithRandomDistance 生成指定方向和随机距离的滑动轨迹
func (api *SlideSimulatorAPI) GenerateSlideWithRandomDistance(startX, startY float64, direction Direction, minDistance, maxDistance float64, deviceID int, pressure float64, size float64) ([]types.TouchEvent, error) {
// 验证输入参数
if minDistance <= 0 || maxDistance < minDistance {
return nil, fmt.Errorf("invalid distance range: minDistance=%.2f, maxDistance=%.2f", minDistance, maxDistance)
}
// 计算实际滑动距离
var actualDistance float64
if minDistance == maxDistance {
actualDistance = minDistance
} else {
actualDistance = minDistance + api.rand.Float64()*(maxDistance-minDistance)
}
// 构建滑动请求
req := SlideRequest{
StartX: startX,
StartY: startY,
Direction: direction,
Distance: actualDistance,
DeviceID: deviceID,
Pressure: pressure,
Size: size,
}
// 生成滑动轨迹
response := api.GenerateSlide(req)
if !response.Success {
return nil, fmt.Errorf("generate slide failed: %s", response.Message)
}
// 转换为TouchEvent
events := api.ConvertToTouchEvents(response.Points)
return events, nil
}
// GenerateSlideInArea 在指定区域内生成滑动轨迹
func (api *SlideSimulatorAPI) GenerateSlideInArea(areaStartX, areaStartY, areaEndX, areaEndY float64, direction Direction, minDistance, maxDistance float64, deviceID int, pressure float64, size float64) ([]types.TouchEvent, error) {
// 验证输入参数
if minDistance <= 0 || maxDistance < minDistance {
return nil, fmt.Errorf("invalid distance range: minDistance=%.2f, maxDistance=%.2f", minDistance, maxDistance)
}
// 验证区域参数允许start和end相等表示单点区域
if areaStartX > areaEndX || areaStartY > areaEndY {
return nil, fmt.Errorf("invalid area: start point (%.2f, %.2f) should be less than or equal to end point (%.2f, %.2f)",
areaStartX, areaStartY, areaEndX, areaEndY)
}
// 在区域内随机选择起始点如果start和end相等则使用固定点
var randomStartX, randomStartY float64
if areaStartX == areaEndX {
randomStartX = areaStartX // 单点X坐标
} else {
areaWidth := areaEndX - areaStartX
randomStartX = areaStartX + api.rand.Float64()*areaWidth
}
if areaStartY == areaEndY {
randomStartY = areaStartY // 单点Y坐标
} else {
areaHeight := areaEndY - areaStartY
randomStartY = areaStartY + api.rand.Float64()*areaHeight
}
// 计算实际滑动距离
var actualDistance float64
if minDistance == maxDistance {
actualDistance = minDistance
} else {
actualDistance = minDistance + api.rand.Float64()*(maxDistance-minDistance)
}
// 验证滑动后的点是否会超出屏幕边界(这里做简单检查)
// 可以根据实际需要调整边界检查逻辑
endX, endY := api.calculateEndPoint(randomStartX, randomStartY, direction, actualDistance)
// 如果滑动后超出合理范围,调整起始点位置
const marginBuffer = 50.0 // 边界缓冲区
switch direction {
case Up:
if endY < marginBuffer {
randomStartY = math.Min(areaEndY-marginBuffer, randomStartY+actualDistance)
}
case Down:
// 这里假设屏幕高度最大为2400可以根据实际需要调整
if endY > 2400-marginBuffer {
randomStartY = math.Max(areaStartY+marginBuffer, randomStartY-actualDistance)
}
case Left:
if endX < marginBuffer {
randomStartX = math.Min(areaEndX-marginBuffer, randomStartX+actualDistance)
}
case Right:
// 这里假设屏幕宽度最大为1800可以根据实际需要调整
if endX > 1800-marginBuffer {
randomStartX = math.Max(areaStartX+marginBuffer, randomStartX-actualDistance)
}
}
// 构建滑动请求
req := SlideRequest{
StartX: randomStartX,
StartY: randomStartY,
Direction: direction,
Distance: actualDistance,
DeviceID: deviceID,
Pressure: pressure,
Size: size,
}
// 生成滑动轨迹
response := api.GenerateSlide(req)
if !response.Success {
return nil, fmt.Errorf("generate slide failed: %s", response.Message)
}
// 转换为TouchEvent
events := api.ConvertToTouchEvents(response.Points)
return events, nil
}
// GeneratePointToPointSlideEvents 生成点对点滑动的TouchEvent序列
func (api *SlideSimulatorAPI) GeneratePointToPointSlideEvents(startX, startY, endX, endY float64, deviceID int, pressure float64, size float64) ([]types.TouchEvent, error) {
// 验证输入参数
if startX == endX && startY == endY {
return nil, fmt.Errorf("start point (%.2f, %.2f) and end point (%.2f, %.2f) cannot be the same", startX, startY, endX, endY)
}
// 计算距离
distance := math.Sqrt((endX-startX)*(endX-startX) + (endY-startY)*(endY-startY))
if distance < 10 {
return nil, fmt.Errorf("distance too short: %.2f pixels", distance)
}
// 构建点对点滑动请求
req := PointToPointSlideRequest{
StartX: startX,
StartY: startY,
EndX: endX,
EndY: endY,
DeviceID: deviceID,
Pressure: pressure,
Size: size,
}
// 生成滑动轨迹
response := api.GeneratePointToPointSlide(req)
if !response.Success {
return nil, fmt.Errorf("generate point to point slide failed: %s", response.Message)
}
// 转换为TouchEvent
events := api.ConvertToTouchEvents(response.Points)
return events, nil
}

View File

@@ -343,9 +343,8 @@ func (g *HTMLReportGenerator) encodeImageToBase64(imagePath string) string {
return ""
}
// Read and compress the image using the unified compression function
// Enable resize with max width 800px for HTML reports
compressedData, err := uixt.CompressImageFile(imagePath, true, 800)
// Read and compress the image with quality 50
compressedData, err := uixt.CompressImageFile(imagePath, 50)
if err != nil {
log.Warn().Err(err).Str("path", imagePath).Msg("failed to compress image, using original")
// Fallback to original image if compression fails

View File

@@ -290,6 +290,100 @@ func (s *StepMobile) SwipeRight(opts ...option.ActionOption) *StepMobile {
return s
}
// SIMSwipeWithDirection performs simulated swipe in specified direction with random distance
func (s *StepMobile) SIMSwipeWithDirection(direction string, fromX, fromY, simMinDistance, simMaxDistance float64, opts ...option.ActionOption) *StepMobile {
// Create params map for SIMSwipeWithDirection
params := map[string]interface{}{
"direction": direction,
"from_x": fromX,
"from_y": fromY,
"sim_min_distance": simMinDistance,
"sim_max_distance": simMaxDistance,
}
action := option.MobileAction{
Method: option.ACTION_SIMSwipeDirection,
Params: params,
Options: option.NewActionOptions(opts...),
}
s.obj().Actions = append(s.obj().Actions, action)
return s
}
// SIMSwipeInArea performs simulated swipe in specified area with direction and random distance
func (s *StepMobile) SIMSwipeInArea(direction string, simAreaStartX, simAreaStartY, simAreaEndX, simAreaEndY, simMinDistance, simMaxDistance float64, opts ...option.ActionOption) *StepMobile {
// Create params map for SIMSwipeInArea
params := map[string]interface{}{
"direction": direction,
"sim_area_start_x": simAreaStartX,
"sim_area_start_y": simAreaStartY,
"sim_area_end_x": simAreaEndX,
"sim_area_end_y": simAreaEndY,
"sim_min_distance": simMinDistance,
"sim_max_distance": simMaxDistance,
}
action := option.MobileAction{
Method: option.ACTION_SIMSwipeInArea,
Params: params,
Options: option.NewActionOptions(opts...),
}
s.obj().Actions = append(s.obj().Actions, action)
return s
}
// SIMSwipeFromPointToPoint performs simulated swipe from point to point
func (s *StepMobile) SIMSwipeFromPointToPoint(fromX, fromY, toX, toY float64, opts ...option.ActionOption) *StepMobile {
// Create params map for SIMSwipeFromPointToPoint
params := map[string]interface{}{
"from_x": fromX,
"from_y": fromY,
"to_x": toX,
"to_y": toY,
}
action := option.MobileAction{
Method: option.ACTION_SIMSwipeFromPointToPoint,
Params: params,
Options: option.NewActionOptions(opts...),
}
s.obj().Actions = append(s.obj().Actions, action)
return s
}
// SIMClickAtPoint performs simulated click at specified point
func (s *StepMobile) SIMClickAtPoint(x, y float64, opts ...option.ActionOption) *StepMobile {
// Create params map for SIMClickAtPoint
params := map[string]interface{}{
"x": x,
"y": y,
}
action := option.MobileAction{
Method: option.ACTION_SIMClickAtPoint,
Params: params,
Options: option.NewActionOptions(opts...),
}
s.obj().Actions = append(s.obj().Actions, action)
return s
}
// SIMInput performs simulated text input with intelligent segmentation
func (s *StepMobile) SIMInput(text string, opts ...option.ActionOption) *StepMobile {
action := option.MobileAction{
Method: option.ACTION_SIMInput,
Params: text,
Options: option.NewActionOptions(opts...),
}
s.obj().Actions = append(s.obj().Actions, action)
return s
}
func (s *StepMobile) SwipeToTapApp(appName string, opts ...option.ActionOption) *StepMobile {
action := option.MobileAction{
Method: option.ACTION_SwipeToTapApp,

View File

@@ -12,6 +12,7 @@ import (
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/internal/simulation"
"github.com/httprunner/httprunner/v5/internal/utf7"
"github.com/httprunner/httprunner/v5/uixt/option"
"github.com/httprunner/httprunner/v5/uixt/types"
@@ -532,7 +533,6 @@ func (ud *UIA2Driver) TouchByEvents(events []types.TouchEvent, opts ...option.Ac
log.Warn().Int("action", event.Action).Msg("Unknown action type, skipping")
continue
}
actions = append(actions, actionMap)
}
@@ -553,6 +553,201 @@ func (ud *UIA2Driver) TouchByEvents(events []types.TouchEvent, opts ...option.Ac
return err
}
// SwipeWithDirection 向指定方向滑动任意距离
// direction: 滑动方向 ("up", "down", "left", "right")
// fromX, fromY: 起始坐标
// simMinDistance, simMaxDistance: 距离范围,如果相等则为固定距离,否则为随机距离
func (ud *UIA2Driver) SIMSwipeWithDirection(direction string, fromX, fromY, simMinDistance, simMaxDistance float64, opts ...option.ActionOption) error {
absStartX, absStartY, err := convertToAbsolutePoint(ud, fromX, fromY)
if err != nil {
return err
}
// 获取设备型号和配置参数
deviceModel, _ := ud.Device.Model()
deviceParams := simulation.GetRandomDeviceParams(deviceModel)
log.Info().Str("direction", direction).
Float64("startX", absStartX).Float64("startY", absStartY).
Float64("minDistance", simMinDistance).Float64("maxDistance", simMaxDistance).
Str("deviceModel", deviceModel).
Int("deviceID", deviceParams.DeviceID).
Float64("pressure", deviceParams.Pressure).
Float64("size", deviceParams.Size).
Msg("UIA2Driver.SwipeWithDirection")
// 导入滑动仿真库
simulator := simulation.NewSlideSimulatorAPI(nil)
// 转换方向字符串为Direction类型
var slideDirection simulation.Direction
switch direction {
case "up":
slideDirection = simulation.Up
case "down":
slideDirection = simulation.Down
case "left":
slideDirection = simulation.Left
case "right":
slideDirection = simulation.Right
default:
return fmt.Errorf("invalid direction: %s, must be one of: up, down, left, right", direction)
}
// 使用滑动仿真算法生成触摸事件序列
events, err := simulator.GenerateSlideWithRandomDistance(
absStartX, absStartY, slideDirection, simMinDistance, simMaxDistance,
deviceParams.DeviceID, deviceParams.Pressure, deviceParams.Size)
if err != nil {
return fmt.Errorf("generate slide events failed: %v", err)
}
// 执行触摸事件序列
return ud.TouchByEvents(events, opts...)
}
// SwipeInArea 在指定区域内向指定方向滑动任意距离
// direction: 滑动方向 ("up", "down", "left", "right")
// simAreaStartX, simAreaStartY, simAreaEndX, simAreaEndY: 区域范围(相对坐标)
// simMinDistance, simMaxDistance: 距离范围,如果相等则为固定距离,否则为随机距离
func (ud *UIA2Driver) SIMSwipeInArea(direction string, simAreaStartX, simAreaStartY, simAreaEndX, simAreaEndY, simMinDistance, simMaxDistance float64, opts ...option.ActionOption) error {
// 转换区域坐标为绝对坐标
absAreaStartX, absAreaStartY, err := convertToAbsolutePoint(ud, simAreaStartX, simAreaStartY)
if err != nil {
return err
}
absAreaEndX, absAreaEndY, err := convertToAbsolutePoint(ud, simAreaEndX, simAreaEndY)
if err != nil {
return err
}
// 确保区域坐标正确(start应该小于等于end)
if absAreaStartX > absAreaEndX {
absAreaStartX, absAreaEndX = absAreaEndX, absAreaStartX
}
if absAreaStartY > absAreaEndY {
absAreaStartY, absAreaEndY = absAreaEndY, absAreaStartY
}
// 获取设备型号和配置参数
deviceModel, _ := ud.Device.Model()
deviceParams := simulation.GetRandomDeviceParams(deviceModel)
log.Info().Str("direction", direction).
Float64("areaStartX", absAreaStartX).Float64("areaStartY", absAreaStartY).
Float64("areaEndX", absAreaEndX).Float64("areaEndY", absAreaEndY).
Float64("minDistance", simMinDistance).Float64("maxDistance", simMaxDistance).
Str("deviceModel", deviceModel).
Int("deviceID", deviceParams.DeviceID).
Float64("pressure", deviceParams.Pressure).
Float64("size", deviceParams.Size).
Msg("UIA2Driver.SwipeInArea")
// 导入滑动仿真库
simulator := simulation.NewSlideSimulatorAPI(nil)
// 转换方向字符串为Direction类型
var slideDirection simulation.Direction
switch direction {
case "up":
slideDirection = simulation.Up
case "down":
slideDirection = simulation.Down
case "left":
slideDirection = simulation.Left
case "right":
slideDirection = simulation.Right
default:
return fmt.Errorf("invalid direction: %s, must be one of: up, down, left, right", direction)
}
// 使用滑动仿真算法生成区域内滑动的触摸事件序列
events, err := simulator.GenerateSlideInArea(
absAreaStartX, absAreaStartY, absAreaEndX, absAreaEndY,
slideDirection, simMinDistance, simMaxDistance,
deviceParams.DeviceID, deviceParams.Pressure, deviceParams.Size)
if err != nil {
return fmt.Errorf("generate slide in area events failed: %v", err)
}
// 执行触摸事件序列
return ud.TouchByEvents(events, opts...)
}
// SwipeFromPointToPoint 指定起始点和结束点进行滑动
// fromX, fromY: 起始坐标(相对坐标)
// toX, toY: 结束坐标(相对坐标)
func (ud *UIA2Driver) SIMSwipeFromPointToPoint(fromX, fromY, toX, toY float64, opts ...option.ActionOption) error {
// 转换起始点和结束点为绝对坐标
absStartX, absStartY, err := convertToAbsolutePoint(ud, fromX, fromY)
if err != nil {
return err
}
absEndX, absEndY, err := convertToAbsolutePoint(ud, toX, toY)
if err != nil {
return err
}
// 获取设备型号和配置参数
deviceModel, _ := ud.Device.Model()
deviceParams := simulation.GetRandomDeviceParams(deviceModel)
log.Info().Float64("startX", absStartX).Float64("startY", absStartY).
Float64("endX", absEndX).Float64("endY", absEndY).
Str("deviceModel", deviceModel).
Int("deviceID", deviceParams.DeviceID).
Float64("pressure", deviceParams.Pressure).
Float64("size", deviceParams.Size).
Msg("UIA2Driver.SwipeFromPointToPoint")
// 导入滑动仿真库
simulator := simulation.NewSlideSimulatorAPI(nil)
// 使用滑动仿真算法生成点对点滑动的触摸事件序列
events, err := simulator.GeneratePointToPointSlideEvents(
absStartX, absStartY, absEndX, absEndY,
deviceParams.DeviceID, deviceParams.Pressure, deviceParams.Size)
if err != nil {
return fmt.Errorf("generate point to point slide events failed: %v", err)
}
// 执行触摸事件序列
return ud.TouchByEvents(events, opts...)
}
// ClickAtPoint 点击相对坐标
// x, y: 点击坐标(相对坐标)
func (ud *UIA2Driver) SIMClickAtPoint(x, y float64, opts ...option.ActionOption) error {
// 转换为绝对坐标
absX, absY, err := convertToAbsolutePoint(ud, x, y)
if err != nil {
return err
}
// 获取设备型号和配置参数
deviceModel, _ := ud.Device.Model()
deviceParams := simulation.GetRandomDeviceParams(deviceModel)
log.Info().Float64("x", absX).Float64("y", absY).
Str("deviceModel", deviceModel).
Int("deviceID", deviceParams.DeviceID).
Float64("pressure", deviceParams.Pressure).
Float64("size", deviceParams.Size).
Msg("UIA2Driver.ClickAtPoint")
// 导入点击仿真库
clickSimulator := simulation.NewClickSimulatorAPI(nil)
// 使用点击仿真算法生成触摸事件序列
events, err := clickSimulator.GenerateClickEvents(
absX, absY, deviceParams.DeviceID, deviceParams.Pressure, deviceParams.Size)
if err != nil {
return fmt.Errorf("generate click events failed: %v", err)
}
// 执行触摸事件序列
return ud.TouchByEvents(events, opts...)
}
func (ud *UIA2Driver) SetPasteboard(contentType types.PasteboardType, content string) (err error) {
log.Info().Str("contentType", string(contentType)).
Str("content", content).Msg("UIA2Driver.SetPasteboard")
@@ -593,6 +788,72 @@ func (ud *UIA2Driver) Input(text string, opts ...option.ActionOption) (err error
return
}
// SIMInput 仿真输入函数,模拟人类分批输入行为
// 将文本智能分割英文单词和数字保持完整中文按1-2个字符分割
func (ud *UIA2Driver) SIMInput(text string, opts ...option.ActionOption) error {
log.Info().Str("text", text).Msg("UIA2Driver.SIMInput")
if text == "" {
return nil
}
// 创建输入仿真器(使用默认配置)
inputSimulator := simulation.NewInputSimulatorAPI(nil)
// 生成输入片段(使用智能分割算法,所有参数使用默认值)
inputReq := simulation.InputRequest{
Text: text,
// MinSegmentLen, MaxSegmentLen, MinDelayMs, MaxDelayMs 使用默认值
}
response := inputSimulator.GenerateInputSegments(inputReq)
if !response.Success {
return fmt.Errorf("failed to generate input segments: %s", response.Message)
}
log.Info().Int("segments", response.Metrics.TotalSegments).
Int("totalDelayMs", response.Metrics.TotalDelayMs).
Int("estimatedTimeMs", response.Metrics.EstimatedTimeMs).
Msg("Input segments generated")
// 逐个输入每个片段
var segmentErrCnt int
for _, segment := range response.Segments {
// 使用SendUnicodeKeys进行输入内部已包含Session.POST请求
segmentErr := ud.SendUnicodeKeys(segment.Text, opts...)
if segmentErr != nil {
segmentErrCnt++
log.Info().Err(segmentErr).Int("segmentErrCnt", segmentErrCnt).
Msg("segments err")
}
log.Debug().Str("segment", segment.Text).Int("index", segment.Index).
Int("charLen", segment.CharLen).Msg("Successfully input segment")
// 如果有延迟时间,则等待
if segment.DelayMs > 0 {
time.Sleep(time.Duration(segment.DelayMs) * time.Millisecond)
log.Debug().Int("delayMs", segment.DelayMs).
Msg("Delay between input segments")
}
}
if segmentErrCnt > 0 {
data := map[string]interface{}{
"text": text,
}
option.MergeOptions(data, opts...)
urlStr := fmt.Sprintf("/session/%s/keys", ud.Session.ID)
_, err := ud.Session.POST(data, urlStr)
return err
}
log.Info().Int("totalSegments", response.Metrics.TotalSegments).
Int("actualDelayMs", response.Metrics.TotalDelayMs).
Msg("SIMInput completed successfully")
return nil
}
func (ud *UIA2Driver) SendUnicodeKeys(text string, opts ...option.ActionOption) (err error) {
log.Info().Str("text", text).Msg("UIA2Driver.SendUnicodeKeys")
// If the Unicode IME is not installed, fall back to the old interface.

View File

@@ -323,7 +323,6 @@ func createXTDriverWithConfig(config DriverCacheConfig) (*XTDriver, error) {
// Default AI options
aiOpts = []option.AIServiceOption{
option.WithCVService(option.CVServiceTypeVEDEM),
option.WithLLMConfig(option.RecommendedConfigurations()["ui_focused"]),
}
}

View File

@@ -15,6 +15,9 @@ var (
_ IDriver = (*WDADriver)(nil)
_ IDriver = (*HDCDriver)(nil)
_ IDriver = (*BrowserDriver)(nil)
// Ensure drivers implement SIMSupport interface
_ SIMSupport = (*UIA2Driver)(nil)
)
// current implemeted driver: ADBDriver, UIA2Driver, WDADriver, HDCDriver
@@ -90,3 +93,13 @@ type IDriver interface {
// clipboard operations
GetPasteboard() (string, error)
}
// SIMSupport interface defines simulated interaction methods
// Any driver that supports simulated touch and input should implement this interface
type SIMSupport interface {
SIMClickAtPoint(x, y float64, opts ...option.ActionOption) error
SIMSwipeWithDirection(direction string, fromX, fromY, simMinDistance, simMaxDistance float64, opts ...option.ActionOption) error
SIMSwipeInArea(direction string, simAreaStartX, simAreaStartY, simAreaEndX, simAreaEndY, simMinDistance, simMaxDistance float64, opts ...option.ActionOption) error
SIMSwipeFromPointToPoint(fromX, fromY, toX, toY float64, opts ...option.ActionOption) error
SIMInput(text string, opts ...option.ActionOption) error
}

View File

@@ -7,7 +7,6 @@ import (
"image"
"image/color"
"image/draw"
"image/gif"
"image/jpeg"
"image/png"
"math"
@@ -244,8 +243,8 @@ func getScreenShotBuffer(driver IDriver) (compressedBufSource *bytes.Buffer, err
"take screenshot failed %v", err)
}
// compress screenshot
compressBufSource, err := compressImageBufferWithOptions(bufSource, false, 800)
// compress screenshot with quality 95
compressBufSource, err := compressImageBufferWithOptions(bufSource, 95)
if err != nil {
return nil, errors.Wrapf(code.DeviceScreenShotError,
"compress screenshot failed %v", err)
@@ -262,11 +261,7 @@ func saveScreenShot(raw *bytes.Buffer, screenshotPath string) error {
log.Error().Err(err).Msg("copy screenshot buffer failed")
}
img, format, err := image.Decode(copiedBuffer)
if err != nil {
return errors.Wrap(err, "decode screenshot image failed")
}
// create file
file, err := os.Create(screenshotPath)
if err != nil {
return errors.Wrap(err, "create screenshot image file failed")
@@ -275,26 +270,10 @@ func saveScreenShot(raw *bytes.Buffer, screenshotPath string) error {
_ = file.Close()
}()
// compress image and save to file
switch format {
case "jpeg":
jpegOptions := &jpeg.Options{Quality: 95}
err = jpeg.Encode(file, img, jpegOptions)
case "png":
encoder := png.Encoder{
CompressionLevel: png.BestCompression,
}
err = encoder.Encode(file, img)
case "gif":
gifOptions := &gif.Options{
NumColors: 256,
}
err = gif.Encode(file, img, gifOptions)
default:
return fmt.Errorf("unsupported image format %s", format)
}
// directly write compressed JPEG data to avoid quality loss
_, err = file.Write(copiedBuffer.Bytes())
if err != nil {
return errors.Wrap(err, "save image file failed")
return errors.Wrap(err, "write image file failed")
}
var fileSize int64
@@ -303,14 +282,14 @@ func saveScreenShot(raw *bytes.Buffer, screenshotPath string) error {
fileSize = fileInfo.Size()
}
log.Info().Str("path", screenshotPath).
Int("rawBytes", raw.Len()).Int64("saveBytes", fileSize).
Int64("fileSize", fileSize).
Msg("save screenshot file success")
return nil
}
// compressImageBufferWithOptions compresses image buffer with advanced options
func compressImageBufferWithOptions(raw *bytes.Buffer, enableResize bool, maxWidth int) (compressed *bytes.Buffer, err error) {
func compressImageBufferWithOptions(raw *bytes.Buffer, quality int) (compressed *bytes.Buffer, err error) {
rawSize := raw.Len()
// decode image from buffer
img, format, err := image.Decode(raw)
@@ -318,32 +297,12 @@ func compressImageBufferWithOptions(raw *bytes.Buffer, enableResize bool, maxWid
return nil, err
}
// Get original image dimensions
bounds := img.Bounds()
originalWidth := bounds.Dx()
originalHeight := bounds.Dy()
// Calculate new dimensions for compression if resize is enabled
var newWidth, newHeight int
var resizedImg image.Image = img
if enableResize && originalWidth > maxWidth {
ratio := float64(maxWidth) / float64(originalWidth)
newWidth = maxWidth
newHeight = int(float64(originalHeight) * ratio)
resizedImg = resizeImage(img, newWidth, newHeight)
} else {
newWidth = originalWidth
newHeight = originalHeight
}
jpegQuality := 95
var buf bytes.Buffer
switch format {
case "jpeg", "jpg", "png":
// compress with compression rate of 95
jpegOptions := &jpeg.Options{Quality: jpegQuality}
err = jpeg.Encode(&buf, resizedImg, jpegOptions)
// compress with compression rate
jpegOptions := &jpeg.Options{Quality: quality}
err = jpeg.Encode(&buf, img, jpegOptions)
if err != nil {
return nil, err
}
@@ -354,55 +313,18 @@ func compressImageBufferWithOptions(raw *bytes.Buffer, enableResize bool, maxWid
compressedSize := buf.Len()
log.Debug().
Int("rawSize", rawSize).
Int("originalWidth", originalWidth).
Int("originalHeight", originalHeight).
Int("newWidth", newWidth).
Int("newHeight", newHeight).
Int("jpegQuality", jpegQuality).
Int("quality", quality).
Int("compressedSize", compressedSize).
Bool("resized", enableResize && originalWidth > maxWidth).
Msg("compress image buffer")
// return compressed image buffer
return &buf, nil
}
// resizeImage resizes an image using simple nearest neighbor algorithm
func resizeImage(src image.Image, width, height int) image.Image {
srcBounds := src.Bounds()
srcWidth := srcBounds.Dx()
srcHeight := srcBounds.Dy()
// Create a new image with the target dimensions
dst := image.NewRGBA(image.Rect(0, 0, width, height))
// Simple nearest neighbor resizing
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
// Map destination coordinates to source coordinates
srcX := x * srcWidth / width
srcY := y * srcHeight / height
// Ensure we don't go out of bounds
if srcX >= srcWidth {
srcX = srcWidth - 1
}
if srcY >= srcHeight {
srcY = srcHeight - 1
}
// Copy pixel from source to destination
dst.Set(x, y, src.At(srcBounds.Min.X+srcX, srcBounds.Min.Y+srcY))
}
}
return dst
}
// CompressImageFile compresses an image file and returns the compressed data
func CompressImageFile(imagePath string, enableResize bool, maxWidth int) ([]byte, error) {
log.Debug().Str("imagePath", imagePath).Bool("enableResize", enableResize).
Int("maxWidth", maxWidth).Msg("compress image file")
func CompressImageFile(imagePath string, quality int) ([]byte, error) {
log.Debug().Str("imagePath", imagePath).
Int("quality", quality).Msg("compress image file")
// Read the original image file
file, err := os.Open(imagePath)
@@ -419,7 +341,7 @@ func CompressImageFile(imagePath string, enableResize bool, maxWidth int) ([]byt
}
// Compress using the buffer compression function
compressedBuf, err := compressImageBufferWithOptions(&buf, enableResize, maxWidth)
compressedBuf, err := compressImageBufferWithOptions(&buf, quality)
if err != nil {
return nil, fmt.Errorf("failed to compress image: %w", err)
}

View File

@@ -87,23 +87,28 @@ func (s *MCPServer4XTDriver) registerTools() {
s.registerTool(&ToolSelectDevice{}) // SelectDevice
// Touch Tools
s.registerTool(&ToolTapXY{}) // tap xy
s.registerTool(&ToolTapAbsXY{}) // tap abs xy
s.registerTool(&ToolTapByOCR{}) // tap by OCR
s.registerTool(&ToolTapByCV{}) // tap by CV
s.registerTool(&ToolDoubleTapXY{}) // double tap xy
s.registerTool(&ToolTapXY{}) // tap xy
s.registerTool(&ToolTapAbsXY{}) // tap abs xy
s.registerTool(&ToolTapByOCR{}) // tap by OCR
s.registerTool(&ToolTapByCV{}) // tap by CV
s.registerTool(&ToolDoubleTapXY{}) // double tap xy
s.registerTool(&ToolSIMClickAtPoint{}) // simulated click at point
// Swipe Tools
s.registerTool(&ToolSwipe{}) // generic swipe, auto-detect direction or coordinate
s.registerTool(&ToolSwipeDirection{}) // swipe direction, up/down/left/right
s.registerTool(&ToolSwipeCoordinate{}) // swipe coordinate, [fromX, fromY, toX, toY]
s.registerTool(&ToolSwipe{}) // generic swipe, auto-detect direction or coordinate
s.registerTool(&ToolSwipeDirection{}) // swipe direction, up/down/left/right
s.registerTool(&ToolSwipeCoordinate{}) // swipe coordinate, [fromX, fromY, toX, toY]
s.registerTool(&ToolSIMSwipeDirection{}) // simulated swipe direction with random distance
s.registerTool(&ToolSIMSwipeInArea{}) // simulated swipe in area with direction and distance
s.registerTool(&ToolSIMSwipeFromPointToPoint{}) // simulated swipe from point to point
s.registerTool(&ToolSwipeToTapApp{})
s.registerTool(&ToolSwipeToTapText{})
s.registerTool(&ToolSwipeToTapTexts{})
s.registerTool(&ToolDrag{})
// Input Tools
s.registerTool(&ToolInput{})
s.registerTool(&ToolInput{}) // regular input
s.registerTool(&ToolSIMInput{}) // simulated input with intelligent segmentation
s.registerTool(&ToolBackspace{})
s.registerTool(&ToolSetIme{})

View File

@@ -6,6 +6,7 @@ import (
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/uixt/option"
)
@@ -192,3 +193,83 @@ func (t *ToolBackspace) ConvertActionToCallToolRequest(action option.MobileActio
}
return BuildMCPCallToolRequest(t.Name(), arguments, action), nil
}
// ToolSIMInput implements the sim_input tool call.
type ToolSIMInput struct {
// Return data fields - these define the structure of data returned by this tool
Text string `json:"text" desc:"Text that was input with simulation"`
Segments int `json:"segments" desc:"Number of segments the text was split into"`
}
func (t *ToolSIMInput) Name() option.ActionName {
return option.ACTION_SIMInput
}
func (t *ToolSIMInput) Description() string {
return "Input text with intelligent segmentation and human-like typing patterns"
}
func (t *ToolSIMInput) Options() []mcp.ToolOption {
unifiedReq := &option.ActionOptions{}
return unifiedReq.GetMCPOptions(option.ACTION_SIMInput)
}
func (t *ToolSIMInput) Implement() server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
arguments := request.GetArguments()
driverExt, err := setupXTDriver(ctx, arguments)
if err != nil {
return nil, fmt.Errorf("setup driver failed: %w", err)
}
unifiedReq, err := parseActionOptions(arguments)
if err != nil {
return nil, err
}
if unifiedReq.Text == "" {
return nil, fmt.Errorf("text is required")
}
text := unifiedReq.Text
log.Info().
Str("text", text).
Int("textLength", len(text)).
Msg("performing simulated input")
opts := unifiedReq.Options()
// Call the underlying SIMInput method (check if driver supports SIM)
if simDriver, ok := driverExt.IDriver.(SIMSupport); ok {
err = simDriver.SIMInput(text, opts...)
if err != nil {
return NewMCPErrorResponse(fmt.Sprintf("Simulated input failed: %s", err.Error())), err
}
} else {
return NewMCPErrorResponse("SIMInput is not supported by the current driver"), fmt.Errorf("driver does not implement SIMSupport interface")
}
// Estimate segments count (this is approximate since the actual segmentation happens in the driver)
estimatedSegments := len([]rune(text))/2 + 1
if estimatedSegments < 1 {
estimatedSegments = 1
}
message := fmt.Sprintf("Successfully performed simulated input: %s", text)
returnData := ToolSIMInput{
Text: text,
Segments: estimatedSegments,
}
return NewMCPSuccessResponse(message, &returnData), nil
}
}
func (t *ToolSIMInput) ConvertActionToCallToolRequest(action option.MobileAction) (mcp.CallToolRequest, error) {
text := fmt.Sprintf("%v", action.Params)
arguments := map[string]any{
"text": text,
}
return BuildMCPCallToolRequest(t.Name(), arguments, action), nil
}

View File

@@ -547,3 +547,412 @@ func (t *ToolDrag) ConvertActionToCallToolRequest(action option.MobileAction) (m
}
return mcp.CallToolRequest{}, fmt.Errorf("invalid drag parameters: %v", action.Params)
}
// ToolSIMSwipeDirection implements the sim_swipe_direction tool call.
type ToolSIMSwipeDirection struct {
// Return data fields - these define the structure of data returned by this tool
Direction string `json:"direction" desc:"Direction that was swiped (up/down/left/right)"`
StartX float64 `json:"startX" desc:"Starting X coordinate of the simulated swipe"`
StartY float64 `json:"startY" desc:"Starting Y coordinate of the simulated swipe"`
MinDistance float64 `json:"minDistance" desc:"Minimum distance of the simulated swipe"`
MaxDistance float64 `json:"maxDistance" desc:"Maximum distance of the simulated swipe"`
ActualDistance float64 `json:"actualDistance" desc:"Actual distance of the simulated swipe"`
}
func (t *ToolSIMSwipeDirection) Name() option.ActionName {
return option.ACTION_SIMSwipeDirection
}
func (t *ToolSIMSwipeDirection) Description() string {
return "Perform simulated swipe in specified direction with random distance and human-like touch patterns"
}
func (t *ToolSIMSwipeDirection) Options() []mcp.ToolOption {
unifiedReq := &option.ActionOptions{}
return unifiedReq.GetMCPOptions(option.ACTION_SIMSwipeDirection)
}
func (t *ToolSIMSwipeDirection) Implement() server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
arguments := request.GetArguments()
driverExt, err := setupXTDriver(ctx, arguments)
if err != nil {
return nil, fmt.Errorf("setup driver failed: %w", err)
}
unifiedReq, err := parseActionOptions(arguments)
if err != nil {
return nil, err
}
// Validate required parameters
if unifiedReq.Direction == nil {
return nil, fmt.Errorf("direction parameter is required")
}
direction, ok := unifiedReq.Direction.(string)
if !ok {
return nil, fmt.Errorf("direction must be a string")
}
// Validate direction
validDirections := []string{"up", "down", "left", "right"}
if !slices.Contains(validDirections, direction) {
return nil, fmt.Errorf("invalid swipe direction: %s, expected one of: %v",
direction, validDirections)
}
// Default values if not provided - use fromX/fromY instead of startX/startY
fromX := unifiedReq.FromX
fromY := unifiedReq.FromY
simMinDistance := unifiedReq.SIMMinDistance
simMaxDistance := unifiedReq.SIMMaxDistance
if fromX == 0 {
fromX = 0.5 // default to center
}
if fromY == 0 {
fromY = 0.5 // default to center
}
if simMinDistance == 0 {
simMinDistance = 100 // default minimum distance
}
if simMaxDistance == 0 {
simMaxDistance = 300 // default maximum distance
}
log.Info().
Str("direction", direction).
Float64("startX", fromX).
Float64("startY", fromY).
Float64("minDistance", simMinDistance).
Float64("maxDistance", simMaxDistance).
Msg("performing simulated swipe with direction")
// Build all options from request arguments
opts := unifiedReq.Options()
// Call the underlying SIMSwipeWithDirection method (check if driver supports SIM)
if simDriver, ok := driverExt.IDriver.(SIMSupport); ok {
err = simDriver.SIMSwipeWithDirection(direction, fromX, fromY, simMinDistance, simMaxDistance, opts...)
if err != nil {
return NewMCPErrorResponse(fmt.Sprintf("Simulated swipe failed: %s", err.Error())), err
}
} else {
return NewMCPErrorResponse("SIMSwipeWithDirection is not supported by the current driver"), fmt.Errorf("driver does not implement SIMSupport interface")
}
// Calculate actual distance for response (approximate)
actualDistance := simMinDistance
if simMaxDistance > simMinDistance {
actualDistance = simMinDistance + (simMaxDistance-simMinDistance)*0.5 // approximate middle value
}
message := fmt.Sprintf("Successfully performed simulated swipe %s from (%.2f, %.2f) with distance %.2f",
direction, fromX, fromY, actualDistance)
returnData := ToolSIMSwipeDirection{
Direction: direction,
StartX: fromX,
StartY: fromY,
MinDistance: simMinDistance,
MaxDistance: simMaxDistance,
ActualDistance: actualDistance,
}
return NewMCPSuccessResponse(message, &returnData), nil
}
}
func (t *ToolSIMSwipeDirection) ConvertActionToCallToolRequest(action option.MobileAction) (mcp.CallToolRequest, error) {
// Handle params as map[string]interface{}
if paramsMap, ok := action.Params.(map[string]interface{}); ok {
arguments := map[string]any{}
// Extract direction
if direction, exists := paramsMap["direction"]; exists {
arguments["direction"] = direction
}
// Extract coordinates and distances - use new field names directly
if fromX, exists := paramsMap["from_x"]; exists {
arguments["from_x"] = fromX
}
if fromY, exists := paramsMap["from_y"]; exists {
arguments["from_y"] = fromY
}
if minDistance, exists := paramsMap["sim_min_distance"]; exists {
arguments["sim_min_distance"] = minDistance
}
if maxDistance, exists := paramsMap["sim_max_distance"]; exists {
arguments["sim_max_distance"] = maxDistance
}
// Add duration and press duration from options
if duration := action.ActionOptions.Duration; duration > 0 {
arguments["duration"] = duration
}
if pressDuration := action.ActionOptions.PressDuration; pressDuration > 0 {
arguments["pressDuration"] = pressDuration
}
return BuildMCPCallToolRequest(t.Name(), arguments, action), nil
}
return mcp.CallToolRequest{}, fmt.Errorf("invalid SIM swipe direction params: %v", action.Params)
}
// ToolSIMSwipeInArea implements the sim_swipe_in_area tool call.
type ToolSIMSwipeInArea struct {
// Return data fields - these define the structure of data returned by this tool
Direction string `json:"direction" desc:"Direction that was swiped (up/down/left/right)"`
AreaStartX float64 `json:"areaStartX" desc:"Area starting X coordinate"`
AreaStartY float64 `json:"areaStartY" desc:"Area starting Y coordinate"`
AreaEndX float64 `json:"areaEndX" desc:"Area ending X coordinate"`
AreaEndY float64 `json:"areaEndY" desc:"Area ending Y coordinate"`
MinDistance float64 `json:"minDistance" desc:"Minimum distance of the simulated swipe"`
MaxDistance float64 `json:"maxDistance" desc:"Maximum distance of the simulated swipe"`
}
func (t *ToolSIMSwipeInArea) Name() option.ActionName {
return option.ACTION_SIMSwipeInArea
}
func (t *ToolSIMSwipeInArea) Description() string {
return "Perform simulated swipe in specified area with direction and random distance"
}
func (t *ToolSIMSwipeInArea) Options() []mcp.ToolOption {
unifiedReq := &option.ActionOptions{}
return unifiedReq.GetMCPOptions(option.ACTION_SIMSwipeInArea)
}
func (t *ToolSIMSwipeInArea) Implement() server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
arguments := request.GetArguments()
driverExt, err := setupXTDriver(ctx, arguments)
if err != nil {
return nil, fmt.Errorf("setup driver failed: %w", err)
}
unifiedReq, err := parseActionOptions(arguments)
if err != nil {
return nil, err
}
// Validate required parameters
if unifiedReq.Direction == nil {
return nil, fmt.Errorf("direction parameter is required")
}
direction, ok := unifiedReq.Direction.(string)
if !ok {
return nil, fmt.Errorf("direction must be a string")
}
// Validate direction
validDirections := []string{"up", "down", "left", "right"}
if !slices.Contains(validDirections, direction) {
return nil, fmt.Errorf("invalid swipe direction: %s, expected one of: %v",
direction, validDirections)
}
// Get area coordinates - use SIM-prefixed fields
simAreaStartX := unifiedReq.SIMAreaStartX
simAreaStartY := unifiedReq.SIMAreaStartY
simAreaEndX := unifiedReq.SIMAreaEndX
simAreaEndY := unifiedReq.SIMAreaEndY
simMinDistance := unifiedReq.SIMMinDistance
simMaxDistance := unifiedReq.SIMMaxDistance
// Default values
if simMinDistance == 0 {
simMinDistance = 100
}
if simMaxDistance == 0 {
simMaxDistance = 300
}
log.Info().
Str("direction", direction).
Float64("areaStartX", simAreaStartX).
Float64("areaStartY", simAreaStartY).
Float64("areaEndX", simAreaEndX).
Float64("areaEndY", simAreaEndY).
Float64("minDistance", simMinDistance).
Float64("maxDistance", simMaxDistance).
Msg("performing simulated swipe in area")
// Build all options from request arguments
opts := unifiedReq.Options()
// Call the underlying SIMSwipeInArea method (check if driver supports SIM)
if simDriver, ok := driverExt.IDriver.(SIMSupport); ok {
err = simDriver.SIMSwipeInArea(direction, simAreaStartX, simAreaStartY, simAreaEndX, simAreaEndY, simMinDistance, simMaxDistance, opts...)
if err != nil {
return NewMCPErrorResponse(fmt.Sprintf("Simulated swipe in area failed: %s", err.Error())), err
}
} else {
return NewMCPErrorResponse("SIMSwipeInArea is not supported by the current driver"), fmt.Errorf("driver does not implement SIMSupport interface")
}
message := fmt.Sprintf("Successfully performed simulated swipe %s in area (%.2f,%.2f)-(%.2f,%.2f)",
direction, simAreaStartX, simAreaStartY, simAreaEndX, simAreaEndY)
returnData := ToolSIMSwipeInArea{
Direction: direction,
AreaStartX: simAreaStartX,
AreaStartY: simAreaStartY,
AreaEndX: simAreaEndX,
AreaEndY: simAreaEndY,
MinDistance: simMinDistance,
MaxDistance: simMaxDistance,
}
return NewMCPSuccessResponse(message, &returnData), nil
}
}
func (t *ToolSIMSwipeInArea) ConvertActionToCallToolRequest(action option.MobileAction) (mcp.CallToolRequest, error) {
// Handle params as map[string]interface{}
if paramsMap, ok := action.Params.(map[string]interface{}); ok {
arguments := map[string]any{}
// Extract direction
if direction, exists := paramsMap["direction"]; exists {
arguments["direction"] = direction
}
// Extract area coordinates and distances - use SIM-prefixed field names
if areaStartX, exists := paramsMap["sim_area_start_x"]; exists {
arguments["sim_area_start_x"] = areaStartX
}
if areaStartY, exists := paramsMap["sim_area_start_y"]; exists {
arguments["sim_area_start_y"] = areaStartY
}
if areaEndX, exists := paramsMap["sim_area_end_x"]; exists {
arguments["sim_area_end_x"] = areaEndX
}
if areaEndY, exists := paramsMap["sim_area_end_y"]; exists {
arguments["sim_area_end_y"] = areaEndY
}
if minDistance, exists := paramsMap["sim_min_distance"]; exists {
arguments["sim_min_distance"] = minDistance
}
if maxDistance, exists := paramsMap["sim_max_distance"]; exists {
arguments["sim_max_distance"] = maxDistance
}
// Add duration and press duration from options
if duration := action.ActionOptions.Duration; duration > 0 {
arguments["duration"] = duration
}
if pressDuration := action.ActionOptions.PressDuration; pressDuration > 0 {
arguments["pressDuration"] = pressDuration
}
return BuildMCPCallToolRequest(t.Name(), arguments, action), nil
}
return mcp.CallToolRequest{}, fmt.Errorf("invalid SIM swipe in area params: %v", action.Params)
}
// ToolSIMSwipeFromPointToPoint implements the sim_swipe_point_to_point tool call.
type ToolSIMSwipeFromPointToPoint struct {
// Return data fields - these define the structure of data returned by this tool
StartX float64 `json:"startX" desc:"Starting X coordinate"`
StartY float64 `json:"startY" desc:"Starting Y coordinate"`
EndX float64 `json:"endX" desc:"Ending X coordinate"`
EndY float64 `json:"endY" desc:"Ending Y coordinate"`
}
func (t *ToolSIMSwipeFromPointToPoint) Name() option.ActionName {
return option.ACTION_SIMSwipeFromPointToPoint
}
func (t *ToolSIMSwipeFromPointToPoint) Description() string {
return "Perform simulated swipe from point to point with human-like touch patterns"
}
func (t *ToolSIMSwipeFromPointToPoint) Options() []mcp.ToolOption {
unifiedReq := &option.ActionOptions{}
return unifiedReq.GetMCPOptions(option.ACTION_SIMSwipeFromPointToPoint)
}
func (t *ToolSIMSwipeFromPointToPoint) Implement() server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
arguments := request.GetArguments()
driverExt, err := setupXTDriver(ctx, arguments)
if err != nil {
return nil, fmt.Errorf("setup driver failed: %w", err)
}
unifiedReq, err := parseActionOptions(arguments)
if err != nil {
return nil, err
}
// Get coordinates from arguments - use fromX/fromY instead of startX/startY
fromX := unifiedReq.FromX
fromY := unifiedReq.FromY
toX := unifiedReq.ToX
toY := unifiedReq.ToY
log.Info().
Float64("startX", fromX).
Float64("startY", fromY).
Float64("endX", toX).
Float64("endY", toY).
Msg("performing simulated point to point swipe")
// Build all options from request arguments
opts := unifiedReq.Options()
// Call the underlying SIMSwipeFromPointToPoint method (check if driver supports SIM)
if simDriver, ok := driverExt.IDriver.(SIMSupport); ok {
err = simDriver.SIMSwipeFromPointToPoint(fromX, fromY, toX, toY, opts...)
if err != nil {
return NewMCPErrorResponse(fmt.Sprintf("Simulated point to point swipe failed: %s", err.Error())), err
}
} else {
return NewMCPErrorResponse("SIMSwipeFromPointToPoint is not supported by the current driver"), fmt.Errorf("driver does not implement SIMSupport interface")
}
message := fmt.Sprintf("Successfully performed simulated swipe from (%.2f,%.2f) to (%.2f,%.2f)",
fromX, fromY, toX, toY)
returnData := ToolSIMSwipeFromPointToPoint{
StartX: fromX,
StartY: fromY,
EndX: toX,
EndY: toY,
}
return NewMCPSuccessResponse(message, &returnData), nil
}
}
func (t *ToolSIMSwipeFromPointToPoint) ConvertActionToCallToolRequest(action option.MobileAction) (mcp.CallToolRequest, error) {
// Handle params as map[string]interface{}
if paramsMap, ok := action.Params.(map[string]interface{}); ok {
arguments := map[string]any{}
// Extract coordinates - use new field names directly
if fromX, exists := paramsMap["from_x"]; exists {
arguments["from_x"] = fromX
}
if fromY, exists := paramsMap["from_y"]; exists {
arguments["from_y"] = fromY
}
if toX, exists := paramsMap["to_x"]; exists {
arguments["to_x"] = toX
}
if toY, exists := paramsMap["to_y"]; exists {
arguments["to_y"] = toY
}
// Add duration and press duration from options
if duration := action.ActionOptions.Duration; duration > 0 {
arguments["duration"] = duration
}
if pressDuration := action.ActionOptions.PressDuration; pressDuration > 0 {
arguments["pressDuration"] = pressDuration
}
return BuildMCPCallToolRequest(t.Name(), arguments, action), nil
}
return mcp.CallToolRequest{}, fmt.Errorf("invalid SIM swipe point to point params: %v", action.Params)
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/uixt/option"
@@ -341,3 +342,95 @@ func (t *ToolDoubleTapXY) ConvertActionToCallToolRequest(action option.MobileAct
}
return mcp.CallToolRequest{}, fmt.Errorf("invalid double tap params: %v", action.Params)
}
// ToolSIMClickAtPoint implements the sim_click_at_point tool call.
type ToolSIMClickAtPoint struct {
// Return data fields - these define the structure of data returned by this tool
X float64 `json:"x" desc:"X coordinate where simulated click was performed"`
Y float64 `json:"y" desc:"Y coordinate where simulated click was performed"`
}
func (t *ToolSIMClickAtPoint) Name() option.ActionName {
return option.ACTION_SIMClickAtPoint
}
func (t *ToolSIMClickAtPoint) Description() string {
return "Perform simulated click at specified point with human-like touch patterns"
}
func (t *ToolSIMClickAtPoint) Options() []mcp.ToolOption {
unifiedReq := &option.ActionOptions{}
return unifiedReq.GetMCPOptions(option.ACTION_SIMClickAtPoint)
}
func (t *ToolSIMClickAtPoint) Implement() server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
arguments := request.GetArguments()
driverExt, err := setupXTDriver(ctx, arguments)
if err != nil {
return nil, fmt.Errorf("setup driver failed: %w", err)
}
unifiedReq, err := parseActionOptions(arguments)
if err != nil {
return nil, err
}
// Validate required parameters
if unifiedReq.X == 0 || unifiedReq.Y == 0 {
return nil, fmt.Errorf("x and y coordinates are required")
}
x := unifiedReq.X
y := unifiedReq.Y
log.Info().
Float64("x", x).
Float64("y", y).
Msg("performing simulated click at point")
// Build all options from request arguments
opts := unifiedReq.Options()
// Call the underlying SIMClickAtPoint method (check if driver supports SIM)
if simDriver, ok := driverExt.IDriver.(SIMSupport); ok {
err = simDriver.SIMClickAtPoint(x, y, opts...)
if err != nil {
return NewMCPErrorResponse(fmt.Sprintf("Simulated click failed: %s", err.Error())), err
}
} else {
return NewMCPErrorResponse("SIMClickAtPoint is not supported by the current driver"), fmt.Errorf("driver does not implement SIMSupport interface")
}
message := fmt.Sprintf("Successfully performed simulated click at (%.2f, %.2f)", x, y)
returnData := ToolSIMClickAtPoint{
X: x,
Y: y,
}
return NewMCPSuccessResponse(message, &returnData), nil
}
}
func (t *ToolSIMClickAtPoint) ConvertActionToCallToolRequest(action option.MobileAction) (mcp.CallToolRequest, error) {
// Handle params as map[string]interface{}
if paramsMap, ok := action.Params.(map[string]interface{}); ok {
arguments := map[string]any{}
// Extract coordinates
if x, exists := paramsMap["x"]; exists {
arguments["x"] = x
}
if y, exists := paramsMap["y"]; exists {
arguments["y"] = y
}
// Add duration from options
if duration := action.ActionOptions.Duration; duration > 0 {
arguments["duration"] = duration
}
return BuildMCPCallToolRequest(t.Name(), arguments, action), nil
}
return mcp.CallToolRequest{}, fmt.Errorf("invalid SIM click at point params: %v", action.Params)
}

View File

@@ -66,9 +66,14 @@ const (
ACTION_TapByCV ActionName = "tap_cv"
ACTION_DoubleTap ActionName = "double_tap" // generic double tap action
ACTION_DoubleTapXY ActionName = "double_tap_xy"
ACTION_Swipe ActionName = "swipe" // swipe by direction or coordinates
ACTION_SwipeDirection ActionName = "swipe_direction" // swipe by direction (up, down, left, right)
ACTION_SwipeCoordinate ActionName = "swipe_coordinate" // swipe by coordinates (fromX, fromY, toX, toY)
ACTION_Swipe ActionName = "swipe" // swipe by direction or coordinates
ACTION_SwipeDirection ActionName = "swipe_direction" // swipe by direction (up, down, left, right)
ACTION_SwipeCoordinate ActionName = "swipe_coordinate" // swipe by coordinates (fromX, fromY, toX, toY)
ACTION_SIMSwipeDirection ActionName = "sim_swipe_direction" // simulated swipe by direction with random distance
ACTION_SIMSwipeInArea ActionName = "sim_swipe_in_area" // simulated swipe in area with direction and distance
ACTION_SIMSwipeFromPointToPoint ActionName = "sim_swipe_point_to_point" // simulated swipe from point to point
ACTION_SIMClickAtPoint ActionName = "sim_click_at_point" // simulated click at point
ACTION_SIMInput ActionName = "sim_input" // simulated text input with segments
ACTION_Drag ActionName = "drag"
ACTION_Input ActionName = "input"
ACTION_PressButton ActionName = "press_button"
@@ -201,9 +206,18 @@ type ActionOptions struct {
PressDuration float64 `json:"press_duration,omitempty" yaml:"press_duration,omitempty" desc:"Press duration in seconds"`
Steps int `json:"steps,omitempty" yaml:"steps,omitempty" desc:"Number of steps for action"`
Direction interface{} `json:"direction,omitempty" yaml:"direction,omitempty" desc:"Direction for swipe operations or custom coordinates"`
Timeout int `json:"timeout,omitempty" yaml:"timeout,omitempty" desc:"Timeout in seconds for action execution"`
TimeLimit int `json:"time_limit,omitempty" yaml:"time_limit,omitempty" desc:"Time limit in seconds for action execution, stops gracefully when reached"`
Frequency int `json:"frequency,omitempty" yaml:"frequency,omitempty" desc:"Action frequency"`
// SIM specific options with SIM prefix
SIMMinDistance float64 `json:"sim_min_distance,omitempty" yaml:"sim_min_distance,omitempty" desc:"Minimum distance for SIM simulated actions"`
SIMMaxDistance float64 `json:"sim_max_distance,omitempty" yaml:"sim_max_distance,omitempty" desc:"Maximum distance for SIM simulated actions"`
SIMAreaStartX float64 `json:"sim_area_start_x,omitempty" yaml:"sim_area_start_x,omitempty" desc:"Area starting X coordinate for SIM simulated swipe"`
SIMAreaStartY float64 `json:"sim_area_start_y,omitempty" yaml:"sim_area_start_y,omitempty" desc:"Area starting Y coordinate for SIM simulated swipe"`
SIMAreaEndX float64 `json:"sim_area_end_x,omitempty" yaml:"sim_area_end_x,omitempty" desc:"Area ending X coordinate for SIM simulated swipe"`
SIMAreaEndY float64 `json:"sim_area_end_y,omitempty" yaml:"sim_area_end_y,omitempty" desc:"Area ending Y coordinate for SIM simulated swipe"`
Timeout int `json:"timeout,omitempty" yaml:"timeout,omitempty" desc:"Timeout in seconds for action execution"`
TimeLimit int `json:"time_limit,omitempty" yaml:"time_limit,omitempty" desc:"Time limit in seconds for action execution, stops gracefully when reached"`
Frequency int `json:"frequency,omitempty" yaml:"frequency,omitempty" desc:"Action frequency"`
ScreenOptions
@@ -649,6 +663,13 @@ func (o *ActionOptions) GetMCPOptions(actionType ActionName) []mcp.ToolOption {
ACTION_Back: {"platform", "serial"},
ACTION_ListPackages: {"platform", "serial"},
ACTION_ClosePopups: {"platform", "serial"},
// SIM specific actions using fromX/fromY for startX/startY and SIM-prefixed fields
ACTION_SIMSwipeDirection: {"platform", "serial", "direction", "fromX", "fromY", "sim_min_distance", "sim_max_distance", "duration", "pressDuration"},
ACTION_SIMSwipeInArea: {"platform", "serial", "direction", "sim_area_start_x", "sim_area_start_y", "sim_area_end_x", "sim_area_end_y", "sim_min_distance", "sim_max_distance", "duration", "pressDuration"},
ACTION_SIMSwipeFromPointToPoint: {"platform", "serial", "fromX", "fromY", "toX", "toY", "duration", "pressDuration"},
ACTION_SIMClickAtPoint: {"platform", "serial", "x", "y", "duration", "pressDuration"},
ACTION_SIMInput: {"platform", "serial", "text", "frequency"},
}
fields := fieldMappings[actionType]