From 67a10ebf053a24c4bab20b5951fa20fc137abd9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=80=E5=85=83?= Date: Thu, 24 Jul 2025 16:41:21 +0800 Subject: [PATCH 01/16] simulation dev --- .../uitest/android_touch_simulator_test.go | 323 ++++++ internal/simulation/click_api.go | 563 +++++++++++ internal/simulation/device_config.go | 152 +++ internal/simulation/slide_api.go | 956 ++++++++++++++++++ uixt/android_driver_uia2.go | 196 ++++ 5 files changed, 2190 insertions(+) create mode 100644 internal/simulation/click_api.go create mode 100644 internal/simulation/device_config.go create mode 100644 internal/simulation/slide_api.go diff --git a/examples/uitest/android_touch_simulator_test.go b/examples/uitest/android_touch_simulator_test.go index fd489fbb..12ece4dd 100644 --- a/examples/uitest/android_touch_simulator_test.go +++ b/examples/uitest/android_touch_simulator_test.go @@ -224,3 +224,326 @@ 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, + }, + //{ + // name: "随机距离下滑", + // direction: "down", + // startX: 0.5, + // startY: 0.5, + // minDistance: 150.0, + // maxDistance: 350.0, // 范围内随机 + //}, + //{ + // name: "固定距离左滑", + // direction: "left", + // startX: 0.5, + // startY: 0.5, + // minDistance: 300.0, + // maxDistance: 300.0, + //}, + //{ + // name: "随机距离右滑", + // direction: "right", + // startX: 0.6, + // startY: 0.5, + // minDistance: 100.0, + // maxDistance: 250.0, + //}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := driver.SwipeWithDirection( + 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.SwipeWithDirection("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.SwipeWithDirection("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.SwipeWithDirection("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.SwipeInArea( + 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.SwipeFromPointToPoint( + 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.SwipeFromPointToPoint(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.SwipeFromPointToPoint(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.ClickAtPoint(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.ClickAtPoint(-0.1, 0.5) + if err == nil { + t.Error("Expected error for negative x coordinate, but got none") + } + + err = driver.ClickAtPoint(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.ClickAtPoint(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") +} diff --git a/internal/simulation/click_api.go b/internal/simulation/click_api.go new file mode 100644 index 00000000..edf813a3 --- /dev/null +++ b/internal/simulation/click_api.go @@ -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: 3, + 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 +} diff --git a/internal/simulation/device_config.go b/internal/simulation/device_config.go new file mode 100644 index 00000000..4197fe88 --- /dev/null +++ b/internal/simulation/device_config.go @@ -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, + } + } +} diff --git a/internal/simulation/slide_api.go b/internal/simulation/slide_api.go new file mode 100644 index 00000000..f5d456d5 --- /dev/null +++ b/internal/simulation/slide_api.go @@ -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 +} diff --git a/uixt/android_driver_uia2.go b/uixt/android_driver_uia2.go index 70390860..68e39ac3 100644 --- a/uixt/android_driver_uia2.go +++ b/uixt/android_driver_uia2.go @@ -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" @@ -553,6 +554,201 @@ func (ud *UIA2Driver) TouchByEvents(events []types.TouchEvent, opts ...option.Ac return err } +// SwipeWithDirection 向指定方向滑动任意距离 +// direction: 滑动方向 ("up", "down", "left", "right") +// startX, startY: 起始坐标 +// minDistance, maxDistance: 距离范围,如果相等则为固定距离,否则为随机距离 +func (ud *UIA2Driver) SwipeWithDirection(direction string, startX, startY, minDistance, maxDistance float64, opts ...option.ActionOption) error { + absStartX, absStartY, err := convertToAbsolutePoint(ud, startX, startY) + 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", minDistance).Float64("maxDistance", maxDistance). + 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, minDistance, maxDistance, + 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") +// areaStartX, areaStartY, areaEndX, areaEndY: 区域范围(相对坐标) +// minDistance, maxDistance: 距离范围,如果相等则为固定距离,否则为随机距离 +func (ud *UIA2Driver) SwipeInArea(direction string, areaStartX, areaStartY, areaEndX, areaEndY, minDistance, maxDistance float64, opts ...option.ActionOption) error { + // 转换区域坐标为绝对坐标 + absAreaStartX, absAreaStartY, err := convertToAbsolutePoint(ud, areaStartX, areaStartY) + if err != nil { + return err + } + absAreaEndX, absAreaEndY, err := convertToAbsolutePoint(ud, areaEndX, areaEndY) + 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", minDistance).Float64("maxDistance", maxDistance). + 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, minDistance, maxDistance, + 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 指定起始点和结束点进行滑动 +// startX, startY: 起始坐标(相对坐标) +// endX, endY: 结束坐标(相对坐标) +func (ud *UIA2Driver) SwipeFromPointToPoint(startX, startY, endX, endY float64, opts ...option.ActionOption) error { + // 转换起始点和结束点为绝对坐标 + absStartX, absStartY, err := convertToAbsolutePoint(ud, startX, startY) + if err != nil { + return err + } + absEndX, absEndY, err := convertToAbsolutePoint(ud, endX, endY) + 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) ClickAtPoint(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") From 5e42f722702378e263d4dade8f2c9e8c3f9505f8 Mon Sep 17 00:00:00 2001 From: "lilong.129" Date: Sun, 27 Jul 2025 22:18:28 +0800 Subject: [PATCH 02/16] change: compress html report image with quality 50 --- internal/version/VERSION | 2 +- report.go | 5 +-- uixt/driver_ext_screenshot.go | 79 +++++------------------------------ 3 files changed, 14 insertions(+), 72 deletions(-) diff --git a/internal/version/VERSION b/internal/version/VERSION index e5858edd..8bbb02ed 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-250724 +v5.0.0-250727 diff --git a/report.go b/report.go index 6c790a68..d9b0cef8 100644 --- a/report.go +++ b/report.go @@ -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 diff --git a/uixt/driver_ext_screenshot.go b/uixt/driver_ext_screenshot.go index 4b5c50d4..8ed0b651 100644 --- a/uixt/driver_ext_screenshot.go +++ b/uixt/driver_ext_screenshot.go @@ -244,8 +244,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) @@ -310,7 +310,7 @@ func saveScreenShot(raw *bytes.Buffer, screenshotPath string) error { } // 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 +318,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 +334,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 +362,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) } From 5dd024fb59d0178cbbaf7fec479a895fd186eb41 Mon Sep 17 00:00:00 2001 From: "lilong.129" Date: Sun, 27 Jul 2025 22:46:50 +0800 Subject: [PATCH 03/16] change: saveScreenShot without second compress --- uixt/driver_ext_screenshot.go | 31 +++++-------------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/uixt/driver_ext_screenshot.go b/uixt/driver_ext_screenshot.go index 8ed0b651..d84c4e1b 100644 --- a/uixt/driver_ext_screenshot.go +++ b/uixt/driver_ext_screenshot.go @@ -7,7 +7,6 @@ import ( "image" "image/color" "image/draw" - "image/gif" "image/jpeg" "image/png" "math" @@ -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,7 +282,7 @@ 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 From ede57d60ef7839e396f8889bf88f44d644ee5b1c Mon Sep 17 00:00:00 2001 From: "lilong.129" Date: Mon, 28 Jul 2025 00:01:26 +0800 Subject: [PATCH 04/16] change: set SendGA4Event timeout to 10s --- internal/sdk/ga4.go | 4 ++-- internal/version/VERSION | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/sdk/ga4.go b/internal/sdk/ga4.go index 6a2995dd..501bb91c 100644 --- a/internal/sdk/ga4.go +++ b/internal/sdk/ga4.go @@ -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") } } diff --git a/internal/version/VERSION b/internal/version/VERSION index 8bbb02ed..db846138 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-250727 +v5.0.0-250728 From 0e7e1d4d3715cfaf062a257ba918b422332055d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=80=E5=85=83?= Date: Mon, 28 Jul 2025 20:49:09 +0800 Subject: [PATCH 05/16] add simulation --- .../uitest/android_touch_simulator_test.go | 94 ++++- internal/simulation/input_api.go | 325 ++++++++++++++++++ uixt/android_driver_uia2.go | 73 +++- 3 files changed, 476 insertions(+), 16 deletions(-) create mode 100644 internal/simulation/input_api.go diff --git a/examples/uitest/android_touch_simulator_test.go b/examples/uitest/android_touch_simulator_test.go index 12ece4dd..3ef41180 100644 --- a/examples/uitest/android_touch_simulator_test.go +++ b/examples/uitest/android_touch_simulator_test.go @@ -284,7 +284,7 @@ func TestSwipeWithDirection(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - err := driver.SwipeWithDirection( + err := driver.SIMSwipeWithDirection( tc.direction, tc.startX, tc.startY, @@ -316,19 +316,19 @@ func TestSwipeWithDirectionInvalidInputs(t *testing.T) { defer driver.TearDown() // Test invalid direction - err = driver.SwipeWithDirection("invalid", 500.0, 500.0, 100.0, 200.0) + 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.SwipeWithDirection("up", 500.0, 500.0, 200.0, 100.0) + 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.SwipeWithDirection("up", 500.0, 500.0, 0.0, 0.0) + 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") } @@ -376,7 +376,7 @@ func TestSwipeInArea(t *testing.T) { for _, tc := range testCases { for i := 0; i < 3; i++ { t.Run(tc.name, func(t *testing.T) { - err := driver.SwipeInArea( + err := driver.SIMSwipeInArea( tc.direction, tc.areaStartX, tc.areaStartY, @@ -429,7 +429,7 @@ func TestSwipeFromPointToPoint(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - err := driver.SwipeFromPointToPoint( + err := driver.SIMSwipeFromPointToPoint( tc.startX, tc.startY, tc.endX, @@ -460,13 +460,13 @@ func TestSwipeFromPointToPointInvalidInputs(t *testing.T) { defer driver.TearDown() // Test same start and end point - err = driver.SwipeFromPointToPoint(0.5, 0.5, 0.5, 0.5) + 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.SwipeFromPointToPoint(0.5, 0.5, 0.501, 0.501) + 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") } @@ -503,7 +503,7 @@ func TestClickAtPoint(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - err := driver.ClickAtPoint(tc.x, tc.y) + err := driver.SIMClickAtPoint(tc.x, tc.y) if err != nil { t.Errorf("ClickAtPoint failed: %v", err) } else { @@ -529,21 +529,91 @@ func TestClickAtPointInvalidInputs(t *testing.T) { defer driver.TearDown() // Test negative coordinates - err = driver.ClickAtPoint(-0.1, 0.5) + err = driver.SIMClickAtPoint(-0.1, 0.5) if err == nil { t.Error("Expected error for negative x coordinate, but got none") } - err = driver.ClickAtPoint(0.5, -0.1) + 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.ClickAtPoint(1.5, 0.5) + 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: "Hello", + //}, + //{ + // name: "英文长文本", + // text: "Hello World! This is a test message.", + //}, + //{ + // name: "日文文本", + // text: "英語の長い文字", + //}, + //{ + // name: "混合文本", + // text: "Hello你好123", + //}, + //{ + // name: "特殊字符", + // text: "!@#$%^&*()", + //}, + //{ + // name: "数字文本", + // text: "1234567890", + //}, + //{ + // name: "空文本", + // text: "", + //}, + //{ + // name: "单个字符", + // text: "A", + //}, + { + 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) + } + }) + } +} diff --git a/internal/simulation/input_api.go b/internal/simulation/input_api.go new file mode 100644 index 00000000..bd63b770 --- /dev/null +++ b/internal/simulation/input_api.go @@ -0,0 +1,325 @@ +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) +} + +// splitTextRandomly 将文本随机分割成指定长度范围的片段(保留原有方法作为备用) +func (api *InputSimulatorAPI) splitTextRandomly(text string, minLen, maxLen int) []string { + var segments []string + runes := []rune(text) // 使用rune来正确处理多字节字符(如中文) + + if minLen <= 0 { + minLen = api.config.MinSegmentLen + } + if maxLen <= 0 { + maxLen = api.config.MaxSegmentLen + } + if maxLen < minLen { + maxLen = minLen + } + + i := 0 + for i < len(runes) { + // 随机决定本次分割的长度 + segmentLength := minLen + if maxLen > minLen { + segmentLength = minLen + api.rand.Intn(maxLen-minLen+1) + } + + // 确保不超出文本长度 + if i+segmentLength > len(runes) { + segmentLength = len(runes) - i + } + + // 提取片段 + segment := string(runes[i : i+segmentLength]) + segments = append(segments, segment) + + i += segmentLength + } + + return segments +} + +// 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) +} diff --git a/uixt/android_driver_uia2.go b/uixt/android_driver_uia2.go index 9072929e..94b7ea6f 100644 --- a/uixt/android_driver_uia2.go +++ b/uixt/android_driver_uia2.go @@ -558,7 +558,7 @@ func (ud *UIA2Driver) TouchByEvents(events []types.TouchEvent, opts ...option.Ac // direction: 滑动方向 ("up", "down", "left", "right") // startX, startY: 起始坐标 // minDistance, maxDistance: 距离范围,如果相等则为固定距离,否则为随机距离 -func (ud *UIA2Driver) SwipeWithDirection(direction string, startX, startY, minDistance, maxDistance float64, opts ...option.ActionOption) error { +func (ud *UIA2Driver) SIMSwipeWithDirection(direction string, startX, startY, minDistance, maxDistance float64, opts ...option.ActionOption) error { absStartX, absStartY, err := convertToAbsolutePoint(ud, startX, startY) if err != nil { return err @@ -610,7 +610,7 @@ func (ud *UIA2Driver) SwipeWithDirection(direction string, startX, startY, minDi // direction: 滑动方向 ("up", "down", "left", "right") // areaStartX, areaStartY, areaEndX, areaEndY: 区域范围(相对坐标) // minDistance, maxDistance: 距离范围,如果相等则为固定距离,否则为随机距离 -func (ud *UIA2Driver) SwipeInArea(direction string, areaStartX, areaStartY, areaEndX, areaEndY, minDistance, maxDistance float64, opts ...option.ActionOption) error { +func (ud *UIA2Driver) SIMSwipeInArea(direction string, areaStartX, areaStartY, areaEndX, areaEndY, minDistance, maxDistance float64, opts ...option.ActionOption) error { // 转换区域坐标为绝对坐标 absAreaStartX, absAreaStartY, err := convertToAbsolutePoint(ud, areaStartX, areaStartY) if err != nil { @@ -677,7 +677,7 @@ func (ud *UIA2Driver) SwipeInArea(direction string, areaStartX, areaStartY, area // SwipeFromPointToPoint 指定起始点和结束点进行滑动 // startX, startY: 起始坐标(相对坐标) // endX, endY: 结束坐标(相对坐标) -func (ud *UIA2Driver) SwipeFromPointToPoint(startX, startY, endX, endY float64, opts ...option.ActionOption) error { +func (ud *UIA2Driver) SIMSwipeFromPointToPoint(startX, startY, endX, endY float64, opts ...option.ActionOption) error { // 转换起始点和结束点为绝对坐标 absStartX, absStartY, err := convertToAbsolutePoint(ud, startX, startY) if err != nil { @@ -717,7 +717,7 @@ func (ud *UIA2Driver) SwipeFromPointToPoint(startX, startY, endX, endY float64, // ClickAtPoint 点击相对坐标 // x, y: 点击坐标(相对坐标) -func (ud *UIA2Driver) ClickAtPoint(x, y float64, opts ...option.ActionOption) error { +func (ud *UIA2Driver) SIMClickAtPoint(x, y float64, opts ...option.ActionOption) error { // 转换为绝对坐标 absX, absY, err := convertToAbsolutePoint(ud, x, y) if err != nil { @@ -789,6 +789,71 @@ 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 segmentErr error + for _, segment := range response.Segments { + // 使用SendUnicodeKeys进行输入(内部已包含Session.POST请求) + segmentErr = ud.SendUnicodeKeys(segment.Text, opts...) + if segmentErr != nil { + log.Info().Err(segmentErr). + 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 segmentErr != nil { + 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. From 2931cf81a0e1003dc2e8e3611233b98ad486bebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=80=E5=85=83?= Date: Mon, 28 Jul 2025 21:06:08 +0800 Subject: [PATCH 06/16] add simulation --- uixt/android_driver_uia2.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/uixt/android_driver_uia2.go b/uixt/android_driver_uia2.go index 94b7ea6f..183e4cdb 100644 --- a/uixt/android_driver_uia2.go +++ b/uixt/android_driver_uia2.go @@ -818,12 +818,13 @@ func (ud *UIA2Driver) SIMInput(text string, opts ...option.ActionOption) error { Msg("Input segments generated") // 逐个输入每个片段 - var segmentErr error + var segmentErrCnt int for _, segment := range response.Segments { // 使用SendUnicodeKeys进行输入(内部已包含Session.POST请求) - segmentErr = ud.SendUnicodeKeys(segment.Text, opts...) + segmentErr := ud.SendUnicodeKeys(segment.Text, opts...) if segmentErr != nil { - log.Info().Err(segmentErr). + segmentErrCnt++ + log.Info().Err(segmentErr).Int("segmentErrCnt", segmentErrCnt). Msg("segments err") } @@ -838,7 +839,7 @@ func (ud *UIA2Driver) SIMInput(text string, opts ...option.ActionOption) error { Msg("Delay between input segments") } } - if segmentErr != nil { + if segmentErrCnt > 0 { data := map[string]interface{}{ "text": text, } From 6374c4becc530a50f350bf46f146b3f39009934b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=80=E5=85=83?= Date: Mon, 28 Jul 2025 21:11:16 +0800 Subject: [PATCH 07/16] fix --- examples/uitest/android_touch_simulator_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/uitest/android_touch_simulator_test.go b/examples/uitest/android_touch_simulator_test.go index 3ef41180..12b3727a 100644 --- a/examples/uitest/android_touch_simulator_test.go +++ b/examples/uitest/android_touch_simulator_test.go @@ -601,7 +601,7 @@ func TestSIMInput(t *testing.T) { //}, { name: "长文本", - text: "This is a very long text to test the performance of SIMInput function. 这是一个很长的文本用来测试SIMInput函数的性能。1234567890!@#$%^&*()英語の長い文字", + text: "This is a very long text to test the performance of SIMInput function. 这是一个很长的文本用来测试SIMInput函数的性能。1234567890!@#$%^&*()英語の長い文", }, } From 11f33307415d50d7ae50b7e0fca37f887ccdf18c Mon Sep 17 00:00:00 2001 From: "huangbin.beal" Date: Mon, 28 Jul 2025 21:13:38 +0800 Subject: [PATCH 08/16] fix: set wings service --- uixt/cache.go | 1 - 1 file changed, 1 deletion(-) diff --git a/uixt/cache.go b/uixt/cache.go index 7ca0c6f0..ca0233b4 100644 --- a/uixt/cache.go +++ b/uixt/cache.go @@ -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"]), } } From 600decab6d7049d2f0156b4db118eacb6419f161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=80=E5=85=83?= Date: Mon, 28 Jul 2025 21:16:28 +0800 Subject: [PATCH 09/16] fix --- internal/simulation/input_api.go | 38 -------------------------------- 1 file changed, 38 deletions(-) diff --git a/internal/simulation/input_api.go b/internal/simulation/input_api.go index bd63b770..3561b560 100644 --- a/internal/simulation/input_api.go +++ b/internal/simulation/input_api.go @@ -257,44 +257,6 @@ func (api *InputSimulatorAPI) isChinese(r rune) bool { return unicode.Is(unicode.Scripts["Han"], r) } -// splitTextRandomly 将文本随机分割成指定长度范围的片段(保留原有方法作为备用) -func (api *InputSimulatorAPI) splitTextRandomly(text string, minLen, maxLen int) []string { - var segments []string - runes := []rune(text) // 使用rune来正确处理多字节字符(如中文) - - if minLen <= 0 { - minLen = api.config.MinSegmentLen - } - if maxLen <= 0 { - maxLen = api.config.MaxSegmentLen - } - if maxLen < minLen { - maxLen = minLen - } - - i := 0 - for i < len(runes) { - // 随机决定本次分割的长度 - segmentLength := minLen - if maxLen > minLen { - segmentLength = minLen + api.rand.Intn(maxLen-minLen+1) - } - - // 确保不超出文本长度 - if i+segmentLength > len(runes) { - segmentLength = len(runes) - i - } - - // 提取片段 - segment := string(runes[i : i+segmentLength]) - segments = append(segments, segment) - - i += segmentLength - } - - return segments -} - // generateRandomDelay 生成随机延迟时间 func (api *InputSimulatorAPI) generateRandomDelay(minDelayMs, maxDelayMs int) int { if minDelayMs < 0 { From 84633a508c985723b2407af5118582f9819bf2e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=80=E5=85=83?= Date: Mon, 28 Jul 2025 21:30:15 +0800 Subject: [PATCH 10/16] restore note --- .../uitest/android_touch_simulator_test.go | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/examples/uitest/android_touch_simulator_test.go b/examples/uitest/android_touch_simulator_test.go index 12b3727a..d4281de8 100644 --- a/examples/uitest/android_touch_simulator_test.go +++ b/examples/uitest/android_touch_simulator_test.go @@ -567,38 +567,38 @@ func TestSIMInput(t *testing.T) { name string text string }{ - //{ - // name: "英文短文本", - // text: "Hello", - //}, - //{ - // name: "英文长文本", - // text: "Hello World! This is a test message.", - //}, - //{ - // name: "日文文本", - // text: "英語の長い文字", - //}, - //{ - // name: "混合文本", - // text: "Hello你好123", - //}, - //{ - // name: "特殊字符", - // text: "!@#$%^&*()", - //}, - //{ - // name: "数字文本", - // text: "1234567890", - //}, - //{ - // name: "空文本", - // text: "", - //}, - //{ - // name: "单个字符", - // text: "A", - //}, + { + name: "英文短文本", + text: "Hello", + }, + { + name: "英文长文本", + text: "Hello World! This is a test message.", + }, + { + name: "日文文本", + text: "英語の長い文字", + }, + { + name: "混合文本", + text: "Hello你好123", + }, + { + name: "特殊字符", + text: "!@#$%^&*()", + }, + { + name: "数字文本", + text: "1234567890", + }, + { + name: "空文本", + text: "", + }, + { + name: "单个字符", + text: "A", + }, { name: "长文本", text: "This is a very long text to test the performance of SIMInput function. 这是一个很长的文本用来测试SIMInput函数的性能。1234567890!@#$%^&*()英語の長い文", From e03a67607613ca91dbfdff0b887162cffd02d779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=80=E5=85=83?= Date: Mon, 28 Jul 2025 21:31:13 +0800 Subject: [PATCH 11/16] restore note --- .../uitest/android_touch_simulator_test.go | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/examples/uitest/android_touch_simulator_test.go b/examples/uitest/android_touch_simulator_test.go index d4281de8..1d46505c 100644 --- a/examples/uitest/android_touch_simulator_test.go +++ b/examples/uitest/android_touch_simulator_test.go @@ -256,30 +256,30 @@ func TestSwipeWithDirection(t *testing.T) { minDistance: 100.0, maxDistance: 500.0, }, - //{ - // name: "随机距离下滑", - // direction: "down", - // startX: 0.5, - // startY: 0.5, - // minDistance: 150.0, - // maxDistance: 350.0, // 范围内随机 - //}, - //{ - // name: "固定距离左滑", - // direction: "left", - // startX: 0.5, - // startY: 0.5, - // minDistance: 300.0, - // maxDistance: 300.0, - //}, - //{ - // name: "随机距离右滑", - // direction: "right", - // startX: 0.6, - // startY: 0.5, - // minDistance: 100.0, - // maxDistance: 250.0, - //}, + { + name: "随机距离下滑", + direction: "down", + startX: 0.5, + startY: 0.5, + minDistance: 150.0, + maxDistance: 350.0, // 范围内随机 + }, + { + name: "固定距离左滑", + direction: "left", + startX: 0.5, + startY: 0.5, + minDistance: 300.0, + maxDistance: 300.0, + }, + { + name: "随机距离右滑", + direction: "right", + startX: 0.6, + startY: 0.5, + minDistance: 100.0, + maxDistance: 250.0, + }, } for _, tc := range testCases { From f20e679855aa239d296e6ad5bce8436b9b13c237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=80=E5=85=83?= Date: Tue, 29 Jul 2025 19:45:30 +0800 Subject: [PATCH 12/16] add simulation --- .../uitest/android_touch_simulator_test.go | 114 ++--- internal/version/VERSION | 2 +- step_ui.go | 94 ++++ uixt/mcp_server.go | 23 +- uixt/mcp_tools_input.go | 81 ++++ uixt/mcp_tools_swipe.go | 409 ++++++++++++++++++ uixt/mcp_tools_touch.go | 93 ++++ uixt/option/action.go | 19 +- 8 files changed, 765 insertions(+), 70 deletions(-) diff --git a/examples/uitest/android_touch_simulator_test.go b/examples/uitest/android_touch_simulator_test.go index 1d46505c..3ef41180 100644 --- a/examples/uitest/android_touch_simulator_test.go +++ b/examples/uitest/android_touch_simulator_test.go @@ -256,30 +256,30 @@ func TestSwipeWithDirection(t *testing.T) { minDistance: 100.0, maxDistance: 500.0, }, - { - name: "随机距离下滑", - direction: "down", - startX: 0.5, - startY: 0.5, - minDistance: 150.0, - maxDistance: 350.0, // 范围内随机 - }, - { - name: "固定距离左滑", - direction: "left", - startX: 0.5, - startY: 0.5, - minDistance: 300.0, - maxDistance: 300.0, - }, - { - name: "随机距离右滑", - direction: "right", - startX: 0.6, - startY: 0.5, - minDistance: 100.0, - maxDistance: 250.0, - }, + //{ + // name: "随机距离下滑", + // direction: "down", + // startX: 0.5, + // startY: 0.5, + // minDistance: 150.0, + // maxDistance: 350.0, // 范围内随机 + //}, + //{ + // name: "固定距离左滑", + // direction: "left", + // startX: 0.5, + // startY: 0.5, + // minDistance: 300.0, + // maxDistance: 300.0, + //}, + //{ + // name: "随机距离右滑", + // direction: "right", + // startX: 0.6, + // startY: 0.5, + // minDistance: 100.0, + // maxDistance: 250.0, + //}, } for _, tc := range testCases { @@ -567,41 +567,41 @@ func TestSIMInput(t *testing.T) { name string text string }{ - { - name: "英文短文本", - text: "Hello", - }, - { - name: "英文长文本", - text: "Hello World! This is a test message.", - }, - { - name: "日文文本", - text: "英語の長い文字", - }, - { - name: "混合文本", - text: "Hello你好123", - }, - { - name: "特殊字符", - text: "!@#$%^&*()", - }, - { - name: "数字文本", - text: "1234567890", - }, - { - name: "空文本", - text: "", - }, - { - name: "单个字符", - text: "A", - }, + //{ + // name: "英文短文本", + // text: "Hello", + //}, + //{ + // name: "英文长文本", + // text: "Hello World! This is a test message.", + //}, + //{ + // name: "日文文本", + // text: "英語の長い文字", + //}, + //{ + // name: "混合文本", + // text: "Hello你好123", + //}, + //{ + // name: "特殊字符", + // text: "!@#$%^&*()", + //}, + //{ + // name: "数字文本", + // text: "1234567890", + //}, + //{ + // name: "空文本", + // text: "", + //}, + //{ + // name: "单个字符", + // text: "A", + //}, { name: "长文本", - text: "This is a very long text to test the performance of SIMInput function. 这是一个很长的文本用来测试SIMInput函数的性能。1234567890!@#$%^&*()英語の長い文", + text: "This is a very long text to test the performance of SIMInput function. 这是一个很长的文本用来测试SIMInput函数的性能。1234567890!@#$%^&*()英語の長い文字", }, } diff --git a/internal/version/VERSION b/internal/version/VERSION index db846138..e0737887 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-250728 +v5.0.0-250729 diff --git a/step_ui.go b/step_ui.go index 9e2d7682..f0e0890a 100644 --- a/step_ui.go +++ b/step_ui.go @@ -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, startX, startY, minDistance, maxDistance float64, opts ...option.ActionOption) *StepMobile { + // Create params map for SIMSwipeWithDirection + params := map[string]interface{}{ + "direction": direction, + "start_x": startX, + "start_y": startY, + "min_distance": minDistance, + "max_distance": maxDistance, + } + + 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, areaStartX, areaStartY, areaEndX, areaEndY, minDistance, maxDistance float64, opts ...option.ActionOption) *StepMobile { + // Create params map for SIMSwipeInArea + params := map[string]interface{}{ + "direction": direction, + "area_start_x": areaStartX, + "area_start_y": areaStartY, + "area_end_x": areaEndX, + "area_end_y": areaEndY, + "min_distance": minDistance, + "max_distance": maxDistance, + } + + 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(startX, startY, endX, endY float64, opts ...option.ActionOption) *StepMobile { + // Create params map for SIMSwipeFromPointToPoint + params := map[string]interface{}{ + "start_x": startX, + "start_y": startY, + "end_x": endX, + "end_y": endY, + } + + 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, diff --git a/uixt/mcp_server.go b/uixt/mcp_server.go index d8f2d4d4..92f0b1be 100644 --- a/uixt/mcp_server.go +++ b/uixt/mcp_server.go @@ -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{}) diff --git a/uixt/mcp_tools_input.go b/uixt/mcp_tools_input.go index 8f4b306e..3341c047 100644 --- a/uixt/mcp_tools_input.go +++ b/uixt/mcp_tools_input.go @@ -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 (Android UIA2 specific) + if uia2Driver, ok := driverExt.IDriver.(*UIA2Driver); ok { + err = uia2Driver.SIMInput(text, opts...) + if err != nil { + return NewMCPErrorResponse(fmt.Sprintf("Simulated input failed: %s", err.Error())), err + } + } else { + return NewMCPErrorResponse("SIMInput is only supported on Android UIA2 driver"), fmt.Errorf("unsupported driver type for SIMInput") + } + + // 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 +} diff --git a/uixt/mcp_tools_swipe.go b/uixt/mcp_tools_swipe.go index 3cbc774b..31df991f 100644 --- a/uixt/mcp_tools_swipe.go +++ b/uixt/mcp_tools_swipe.go @@ -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 + startX := unifiedReq.StartX + startY := unifiedReq.StartY + minDistance := unifiedReq.MinDistance + maxDistance := unifiedReq.MaxDistance + + if startX == 0 { + startX = 0.5 // default to center + } + if startY == 0 { + startY = 0.5 // default to center + } + if minDistance == 0 { + minDistance = 100 // default minimum distance + } + if maxDistance == 0 { + maxDistance = 300 // default maximum distance + } + + log.Info(). + Str("direction", direction). + Float64("startX", startX). + Float64("startY", startY). + Float64("minDistance", minDistance). + Float64("maxDistance", maxDistance). + Msg("performing simulated swipe with direction") + + // Build all options from request arguments + opts := unifiedReq.Options() + + // Call the underlying SIMSwipeWithDirection method (Android UIA2 specific) + if uia2Driver, ok := driverExt.IDriver.(*UIA2Driver); ok { + err = uia2Driver.SIMSwipeWithDirection(direction, startX, startY, minDistance, maxDistance, opts...) + if err != nil { + return NewMCPErrorResponse(fmt.Sprintf("Simulated swipe failed: %s", err.Error())), err + } + } else { + return NewMCPErrorResponse("SIMSwipeWithDirection is only supported on Android UIA2 driver"), fmt.Errorf("unsupported driver type for SIMSwipeWithDirection") + } + + // Calculate actual distance for response (approximate) + actualDistance := minDistance + if maxDistance > minDistance { + actualDistance = minDistance + (maxDistance-minDistance)*0.5 // approximate middle value + } + + message := fmt.Sprintf("Successfully performed simulated swipe %s from (%.2f, %.2f) with distance %.2f", + direction, startX, startY, actualDistance) + returnData := ToolSIMSwipeDirection{ + Direction: direction, + StartX: startX, + StartY: startY, + MinDistance: minDistance, + MaxDistance: maxDistance, + 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 + if startX, exists := paramsMap["start_x"]; exists { + arguments["start_x"] = startX + } + if startY, exists := paramsMap["start_y"]; exists { + arguments["start_y"] = startY + } + if minDistance, exists := paramsMap["min_distance"]; exists { + arguments["min_distance"] = minDistance + } + if maxDistance, exists := paramsMap["max_distance"]; exists { + arguments["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 + areaStartX := unifiedReq.AreaStartX + areaStartY := unifiedReq.AreaStartY + areaEndX := unifiedReq.AreaEndX + areaEndY := unifiedReq.AreaEndY + minDistance := unifiedReq.MinDistance + maxDistance := unifiedReq.MaxDistance + + // Default values + if minDistance == 0 { + minDistance = 100 + } + if maxDistance == 0 { + maxDistance = 300 + } + + log.Info(). + Str("direction", direction). + Float64("areaStartX", areaStartX). + Float64("areaStartY", areaStartY). + Float64("areaEndX", areaEndX). + Float64("areaEndY", areaEndY). + Float64("minDistance", minDistance). + Float64("maxDistance", maxDistance). + Msg("performing simulated swipe in area") + + // Build all options from request arguments + opts := unifiedReq.Options() + + // Call the underlying SIMSwipeInArea method (Android UIA2 specific) + if uia2Driver, ok := driverExt.IDriver.(*UIA2Driver); ok { + err = uia2Driver.SIMSwipeInArea(direction, areaStartX, areaStartY, areaEndX, areaEndY, minDistance, maxDistance, opts...) + if err != nil { + return NewMCPErrorResponse(fmt.Sprintf("Simulated swipe in area failed: %s", err.Error())), err + } + } else { + return NewMCPErrorResponse("SIMSwipeInArea is only supported on Android UIA2 driver"), fmt.Errorf("unsupported driver type for SIMSwipeInArea") + } + + message := fmt.Sprintf("Successfully performed simulated swipe %s in area (%.2f,%.2f)-(%.2f,%.2f)", + direction, areaStartX, areaStartY, areaEndX, areaEndY) + returnData := ToolSIMSwipeInArea{ + Direction: direction, + AreaStartX: areaStartX, + AreaStartY: areaStartY, + AreaEndX: areaEndX, + AreaEndY: areaEndY, + MinDistance: minDistance, + MaxDistance: maxDistance, + } + + 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 + if areaStartX, exists := paramsMap["area_start_x"]; exists { + arguments["area_start_x"] = areaStartX + } + if areaStartY, exists := paramsMap["area_start_y"]; exists { + arguments["area_start_y"] = areaStartY + } + if areaEndX, exists := paramsMap["area_end_x"]; exists { + arguments["area_end_x"] = areaEndX + } + if areaEndY, exists := paramsMap["area_end_y"]; exists { + arguments["area_end_y"] = areaEndY + } + if minDistance, exists := paramsMap["min_distance"]; exists { + arguments["min_distance"] = minDistance + } + if maxDistance, exists := paramsMap["max_distance"]; exists { + arguments["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 + startX := unifiedReq.StartX + startY := unifiedReq.StartY + endX := unifiedReq.ToX // Using existing ToX field + endY := unifiedReq.ToY // Using existing ToY field + + log.Info(). + Float64("startX", startX). + Float64("startY", startY). + Float64("endX", endX). + Float64("endY", endY). + Msg("performing simulated point to point swipe") + + // Build all options from request arguments + opts := unifiedReq.Options() + + // Call the underlying SIMSwipeFromPointToPoint method (Android UIA2 specific) + if uia2Driver, ok := driverExt.IDriver.(*UIA2Driver); ok { + err = uia2Driver.SIMSwipeFromPointToPoint(startX, startY, endX, endY, opts...) + if err != nil { + return NewMCPErrorResponse(fmt.Sprintf("Simulated point to point swipe failed: %s", err.Error())), err + } + } else { + return NewMCPErrorResponse("SIMSwipeFromPointToPoint is only supported on Android UIA2 driver"), fmt.Errorf("unsupported driver type for SIMSwipeFromPointToPoint") + } + + message := fmt.Sprintf("Successfully performed simulated swipe from (%.2f,%.2f) to (%.2f,%.2f)", + startX, startY, endX, endY) + returnData := ToolSIMSwipeFromPointToPoint{ + StartX: startX, + StartY: startY, + EndX: endX, + EndY: endY, + } + + 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 + if startX, exists := paramsMap["start_x"]; exists { + arguments["start_x"] = startX + } + if startY, exists := paramsMap["start_y"]; exists { + arguments["start_y"] = startY + } + if endX, exists := paramsMap["end_x"]; exists { + arguments["to_x"] = endX // Map to existing ToX field + } + if endY, exists := paramsMap["end_y"]; exists { + arguments["to_y"] = endY // Map to existing ToY field + } + + // 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) +} diff --git a/uixt/mcp_tools_touch.go b/uixt/mcp_tools_touch.go index f78d7ef1..751b7620 100644 --- a/uixt/mcp_tools_touch.go +++ b/uixt/mcp_tools_touch.go @@ -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 (Android UIA2 specific) + if uia2Driver, ok := driverExt.IDriver.(*UIA2Driver); ok { + err = uia2Driver.SIMClickAtPoint(x, y, opts...) + if err != nil { + return NewMCPErrorResponse(fmt.Sprintf("Simulated click failed: %s", err.Error())), err + } + } else { + return NewMCPErrorResponse("SIMClickAtPoint is only supported on Android UIA2 driver"), fmt.Errorf("unsupported driver type for SIMClickAtPoint") + } + + 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) +} diff --git a/uixt/option/action.go b/uixt/option/action.go index a6f40736..f165cf36 100644 --- a/uixt/option/action.go +++ b/uixt/option/action.go @@ -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,6 +206,14 @@ 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"` + StartX float64 `json:"start_x,omitempty" yaml:"start_x,omitempty" desc:"Starting X coordinate for simulated swipe"` + StartY float64 `json:"start_y,omitempty" yaml:"start_y,omitempty" desc:"Starting Y coordinate for simulated swipe"` + MinDistance float64 `json:"min_distance,omitempty" yaml:"min_distance,omitempty" desc:"Minimum distance for simulated swipe"` + MaxDistance float64 `json:"max_distance,omitempty" yaml:"max_distance,omitempty" desc:"Maximum distance for simulated swipe"` + AreaStartX float64 `json:"area_start_x,omitempty" yaml:"area_start_x,omitempty" desc:"Area starting X coordinate for simulated swipe"` + AreaStartY float64 `json:"area_start_y,omitempty" yaml:"area_start_y,omitempty" desc:"Area starting Y coordinate for simulated swipe"` + AreaEndX float64 `json:"area_end_x,omitempty" yaml:"area_end_x,omitempty" desc:"Area ending X coordinate for simulated swipe"` + AreaEndY float64 `json:"area_end_y,omitempty" yaml:"area_end_y,omitempty" desc:"Area ending Y coordinate for 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"` From 9b529eb125a9b9bf2b8b3715d8eaa171e010c721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=80=E5=85=83?= Date: Tue, 29 Jul 2025 20:01:44 +0800 Subject: [PATCH 13/16] fix test --- .../uitest/android_touch_simulator_test.go | 56 ------------------- 1 file changed, 56 deletions(-) diff --git a/examples/uitest/android_touch_simulator_test.go b/examples/uitest/android_touch_simulator_test.go index 3ef41180..588265d0 100644 --- a/examples/uitest/android_touch_simulator_test.go +++ b/examples/uitest/android_touch_simulator_test.go @@ -256,30 +256,6 @@ func TestSwipeWithDirection(t *testing.T) { minDistance: 100.0, maxDistance: 500.0, }, - //{ - // name: "随机距离下滑", - // direction: "down", - // startX: 0.5, - // startY: 0.5, - // minDistance: 150.0, - // maxDistance: 350.0, // 范围内随机 - //}, - //{ - // name: "固定距离左滑", - // direction: "left", - // startX: 0.5, - // startY: 0.5, - // minDistance: 300.0, - // maxDistance: 300.0, - //}, - //{ - // name: "随机距离右滑", - // direction: "right", - // startX: 0.6, - // startY: 0.5, - // minDistance: 100.0, - // maxDistance: 250.0, - //}, } for _, tc := range testCases { @@ -567,38 +543,6 @@ func TestSIMInput(t *testing.T) { name string text string }{ - //{ - // name: "英文短文本", - // text: "Hello", - //}, - //{ - // name: "英文长文本", - // text: "Hello World! This is a test message.", - //}, - //{ - // name: "日文文本", - // text: "英語の長い文字", - //}, - //{ - // name: "混合文本", - // text: "Hello你好123", - //}, - //{ - // name: "特殊字符", - // text: "!@#$%^&*()", - //}, - //{ - // name: "数字文本", - // text: "1234567890", - //}, - //{ - // name: "空文本", - // text: "", - //}, - //{ - // name: "单个字符", - // text: "A", - //}, { name: "长文本", text: "This is a very long text to test the performance of SIMInput function. 这是一个很长的文本用来测试SIMInput函数的性能。1234567890!@#$%^&*()英語の長い文字", From ff13d907552787da09aac4c62ccf7b16a4486fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=80=E5=85=83?= Date: Wed, 30 Jul 2025 11:18:26 +0800 Subject: [PATCH 14/16] add mcp_sim --- .../uitest/android_touch_simulator_test.go | 41 +++++++++++++++++++ internal/simulation/click_api.go | 2 +- internal/version/VERSION | 2 +- uixt/android_driver_adb.go | 23 +++++++++++ uixt/android_driver_uia2.go | 2 +- uixt/driver.go | 14 +++++++ uixt/mcp_tools_input.go | 8 ++-- uixt/mcp_tools_swipe.go | 24 +++++------ uixt/mcp_tools_touch.go | 8 ++-- 9 files changed, 101 insertions(+), 23 deletions(-) diff --git a/examples/uitest/android_touch_simulator_test.go b/examples/uitest/android_touch_simulator_test.go index 588265d0..cec638bb 100644 --- a/examples/uitest/android_touch_simulator_test.go +++ b/examples/uitest/android_touch_simulator_test.go @@ -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" @@ -561,3 +563,42 @@ func TestSIMInput(t *testing.T) { }) } } + +// 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") +} diff --git a/internal/simulation/click_api.go b/internal/simulation/click_api.go index edf813a3..dbc8c2ed 100644 --- a/internal/simulation/click_api.go +++ b/internal/simulation/click_api.go @@ -61,7 +61,7 @@ type ClickConfig struct { var DefaultClickConfig = ClickConfig{ MinDuration: 40, MaxDuration: 90, - MinPoints: 3, + MinPoints: 4, // 增加最小点数从3到4,确保至少有2个MOVE事件 MaxPoints: 6, MaxDeviation: 2.0, NoiseLevel: 0.5, diff --git a/internal/version/VERSION b/internal/version/VERSION index e0737887..dc1711bb 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-250729 +v5.0.0-250730 diff --git a/uixt/android_driver_adb.go b/uixt/android_driver_adb.go index 0f54b9c3..070fe47d 100644 --- a/uixt/android_driver_adb.go +++ b/uixt/android_driver_adb.go @@ -1273,3 +1273,26 @@ func (ad *ADBDriver) SecondaryClick(x, y float64) (err error) { func (ad *ADBDriver) SecondaryClickBySelector(selector string, options ...option.ActionOption) (err error) { return err } + +// SIMSupport interface implementation for ADBDriver +// These methods return "not supported" errors since SIM functionality is only available in UIA2Driver + +func (ad *ADBDriver) SIMClickAtPoint(x, y float64, opts ...option.ActionOption) error { + return fmt.Errorf("SIMClickAtPoint is not supported in ADBDriver, please use UIA2Driver instead") +} + +func (ad *ADBDriver) SIMSwipeWithDirection(direction string, startX, startY, minDistance, maxDistance float64, opts ...option.ActionOption) error { + return fmt.Errorf("SIMSwipeWithDirection is not supported in ADBDriver, please use UIA2Driver instead") +} + +func (ad *ADBDriver) SIMSwipeInArea(direction string, areaStartX, areaStartY, areaEndX, areaEndY, minDistance, maxDistance float64, opts ...option.ActionOption) error { + return fmt.Errorf("SIMSwipeInArea is not supported in ADBDriver, please use UIA2Driver instead") +} + +func (ad *ADBDriver) SIMSwipeFromPointToPoint(startX, startY, endX, endY float64, opts ...option.ActionOption) error { + return fmt.Errorf("SIMSwipeFromPointToPoint is not supported in ADBDriver, please use UIA2Driver instead") +} + +func (ad *ADBDriver) SIMInput(text string, opts ...option.ActionOption) error { + return fmt.Errorf("SIMInput is not supported in ADBDriver, please use UIA2Driver instead") +} diff --git a/uixt/android_driver_uia2.go b/uixt/android_driver_uia2.go index 183e4cdb..3919c98e 100644 --- a/uixt/android_driver_uia2.go +++ b/uixt/android_driver_uia2.go @@ -533,7 +533,7 @@ func (ud *UIA2Driver) TouchByEvents(events []types.TouchEvent, opts ...option.Ac log.Warn().Int("action", event.Action).Msg("Unknown action type, skipping") continue } - + log.Warn().Any("actionMap", actionMap).Msg("ActionMap") actions = append(actions, actionMap) } diff --git a/uixt/driver.go b/uixt/driver.go index 58d31fc6..81a871e5 100644 --- a/uixt/driver.go +++ b/uixt/driver.go @@ -15,6 +15,10 @@ var ( _ IDriver = (*WDADriver)(nil) _ IDriver = (*HDCDriver)(nil) _ IDriver = (*BrowserDriver)(nil) + + // Ensure drivers implement SIMSupport interface + _ SIMSupport = (*UIA2Driver)(nil) + _ SIMSupport = (*ADBDriver)(nil) ) // current implemeted driver: ADBDriver, UIA2Driver, WDADriver, HDCDriver @@ -90,3 +94,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, startX, startY, minDistance, maxDistance float64, opts ...option.ActionOption) error + SIMSwipeInArea(direction string, areaStartX, areaStartY, areaEndX, areaEndY, minDistance, maxDistance float64, opts ...option.ActionOption) error + SIMSwipeFromPointToPoint(startX, startY, endX, endY float64, opts ...option.ActionOption) error + SIMInput(text string, opts ...option.ActionOption) error +} diff --git a/uixt/mcp_tools_input.go b/uixt/mcp_tools_input.go index 3341c047..64198125 100644 --- a/uixt/mcp_tools_input.go +++ b/uixt/mcp_tools_input.go @@ -240,14 +240,14 @@ func (t *ToolSIMInput) Implement() server.ToolHandlerFunc { opts := unifiedReq.Options() - // Call the underlying SIMInput method (Android UIA2 specific) - if uia2Driver, ok := driverExt.IDriver.(*UIA2Driver); ok { - err = uia2Driver.SIMInput(text, opts...) + // 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 only supported on Android UIA2 driver"), fmt.Errorf("unsupported driver type for SIMInput") + 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) diff --git a/uixt/mcp_tools_swipe.go b/uixt/mcp_tools_swipe.go index 31df991f..a889ae7f 100644 --- a/uixt/mcp_tools_swipe.go +++ b/uixt/mcp_tools_swipe.go @@ -631,14 +631,14 @@ func (t *ToolSIMSwipeDirection) Implement() server.ToolHandlerFunc { // Build all options from request arguments opts := unifiedReq.Options() - // Call the underlying SIMSwipeWithDirection method (Android UIA2 specific) - if uia2Driver, ok := driverExt.IDriver.(*UIA2Driver); ok { - err = uia2Driver.SIMSwipeWithDirection(direction, startX, startY, minDistance, maxDistance, opts...) + // Call the underlying SIMSwipeWithDirection method (check if driver supports SIM) + if simDriver, ok := driverExt.IDriver.(SIMSupport); ok { + err = simDriver.SIMSwipeWithDirection(direction, startX, startY, minDistance, maxDistance, opts...) if err != nil { return NewMCPErrorResponse(fmt.Sprintf("Simulated swipe failed: %s", err.Error())), err } } else { - return NewMCPErrorResponse("SIMSwipeWithDirection is only supported on Android UIA2 driver"), fmt.Errorf("unsupported driver type for SIMSwipeWithDirection") + return NewMCPErrorResponse("SIMSwipeWithDirection is not supported by the current driver"), fmt.Errorf("driver does not implement SIMSupport interface") } // Calculate actual distance for response (approximate) @@ -782,14 +782,14 @@ func (t *ToolSIMSwipeInArea) Implement() server.ToolHandlerFunc { // Build all options from request arguments opts := unifiedReq.Options() - // Call the underlying SIMSwipeInArea method (Android UIA2 specific) - if uia2Driver, ok := driverExt.IDriver.(*UIA2Driver); ok { - err = uia2Driver.SIMSwipeInArea(direction, areaStartX, areaStartY, areaEndX, areaEndY, minDistance, maxDistance, opts...) + // Call the underlying SIMSwipeInArea method (check if driver supports SIM) + if simDriver, ok := driverExt.IDriver.(SIMSupport); ok { + err = simDriver.SIMSwipeInArea(direction, areaStartX, areaStartY, areaEndX, areaEndY, minDistance, maxDistance, opts...) if err != nil { return NewMCPErrorResponse(fmt.Sprintf("Simulated swipe in area failed: %s", err.Error())), err } } else { - return NewMCPErrorResponse("SIMSwipeInArea is only supported on Android UIA2 driver"), fmt.Errorf("unsupported driver type for SIMSwipeInArea") + 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)", @@ -902,14 +902,14 @@ func (t *ToolSIMSwipeFromPointToPoint) Implement() server.ToolHandlerFunc { // Build all options from request arguments opts := unifiedReq.Options() - // Call the underlying SIMSwipeFromPointToPoint method (Android UIA2 specific) - if uia2Driver, ok := driverExt.IDriver.(*UIA2Driver); ok { - err = uia2Driver.SIMSwipeFromPointToPoint(startX, startY, endX, endY, opts...) + // Call the underlying SIMSwipeFromPointToPoint method (check if driver supports SIM) + if simDriver, ok := driverExt.IDriver.(SIMSupport); ok { + err = simDriver.SIMSwipeFromPointToPoint(startX, startY, endX, endY, opts...) if err != nil { return NewMCPErrorResponse(fmt.Sprintf("Simulated point to point swipe failed: %s", err.Error())), err } } else { - return NewMCPErrorResponse("SIMSwipeFromPointToPoint is only supported on Android UIA2 driver"), fmt.Errorf("unsupported driver type for SIMSwipeFromPointToPoint") + 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)", diff --git a/uixt/mcp_tools_touch.go b/uixt/mcp_tools_touch.go index 751b7620..ac35b3c2 100644 --- a/uixt/mcp_tools_touch.go +++ b/uixt/mcp_tools_touch.go @@ -392,14 +392,14 @@ func (t *ToolSIMClickAtPoint) Implement() server.ToolHandlerFunc { // Build all options from request arguments opts := unifiedReq.Options() - // Call the underlying SIMClickAtPoint method (Android UIA2 specific) - if uia2Driver, ok := driverExt.IDriver.(*UIA2Driver); ok { - err = uia2Driver.SIMClickAtPoint(x, y, opts...) + // 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 only supported on Android UIA2 driver"), fmt.Errorf("unsupported driver type for SIMClickAtPoint") + 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) From 9ef98f828d574ad03fcc763fd1b8367c5fd3c226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=80=E5=85=83?= Date: Wed, 30 Jul 2025 11:21:38 +0800 Subject: [PATCH 15/16] fix --- uixt/android_driver_uia2.go | 1 - 1 file changed, 1 deletion(-) diff --git a/uixt/android_driver_uia2.go b/uixt/android_driver_uia2.go index 3919c98e..2997d030 100644 --- a/uixt/android_driver_uia2.go +++ b/uixt/android_driver_uia2.go @@ -533,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 } - log.Warn().Any("actionMap", actionMap).Msg("ActionMap") actions = append(actions, actionMap) } From f580ea4168e62e1629b51347c34abd2d2e140b6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=80=E5=85=83?= Date: Wed, 30 Jul 2025 15:17:40 +0800 Subject: [PATCH 16/16] fix --- step_ui.go | 38 +++---- uixt/android_driver_adb.go | 23 ----- uixt/android_driver_uia2.go | 36 +++---- uixt/driver.go | 7 +- uixt/mcp_tools_swipe.go | 194 ++++++++++++++++++------------------ uixt/option/action.go | 30 ++++-- 6 files changed, 156 insertions(+), 172 deletions(-) diff --git a/step_ui.go b/step_ui.go index f0e0890a..ae4a6233 100644 --- a/step_ui.go +++ b/step_ui.go @@ -291,14 +291,14 @@ func (s *StepMobile) SwipeRight(opts ...option.ActionOption) *StepMobile { } // SIMSwipeWithDirection performs simulated swipe in specified direction with random distance -func (s *StepMobile) SIMSwipeWithDirection(direction string, startX, startY, minDistance, maxDistance float64, opts ...option.ActionOption) *StepMobile { +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, - "start_x": startX, - "start_y": startY, - "min_distance": minDistance, - "max_distance": maxDistance, + "direction": direction, + "from_x": fromX, + "from_y": fromY, + "sim_min_distance": simMinDistance, + "sim_max_distance": simMaxDistance, } action := option.MobileAction{ @@ -312,16 +312,16 @@ func (s *StepMobile) SIMSwipeWithDirection(direction string, startX, startY, min } // SIMSwipeInArea performs simulated swipe in specified area with direction and random distance -func (s *StepMobile) SIMSwipeInArea(direction string, areaStartX, areaStartY, areaEndX, areaEndY, minDistance, maxDistance float64, opts ...option.ActionOption) *StepMobile { +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, - "area_start_x": areaStartX, - "area_start_y": areaStartY, - "area_end_x": areaEndX, - "area_end_y": areaEndY, - "min_distance": minDistance, - "max_distance": maxDistance, + "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{ @@ -335,13 +335,13 @@ func (s *StepMobile) SIMSwipeInArea(direction string, areaStartX, areaStartY, ar } // SIMSwipeFromPointToPoint performs simulated swipe from point to point -func (s *StepMobile) SIMSwipeFromPointToPoint(startX, startY, endX, endY float64, opts ...option.ActionOption) *StepMobile { +func (s *StepMobile) SIMSwipeFromPointToPoint(fromX, fromY, toX, toY float64, opts ...option.ActionOption) *StepMobile { // Create params map for SIMSwipeFromPointToPoint params := map[string]interface{}{ - "start_x": startX, - "start_y": startY, - "end_x": endX, - "end_y": endY, + "from_x": fromX, + "from_y": fromY, + "to_x": toX, + "to_y": toY, } action := option.MobileAction{ diff --git a/uixt/android_driver_adb.go b/uixt/android_driver_adb.go index 070fe47d..0f54b9c3 100644 --- a/uixt/android_driver_adb.go +++ b/uixt/android_driver_adb.go @@ -1273,26 +1273,3 @@ func (ad *ADBDriver) SecondaryClick(x, y float64) (err error) { func (ad *ADBDriver) SecondaryClickBySelector(selector string, options ...option.ActionOption) (err error) { return err } - -// SIMSupport interface implementation for ADBDriver -// These methods return "not supported" errors since SIM functionality is only available in UIA2Driver - -func (ad *ADBDriver) SIMClickAtPoint(x, y float64, opts ...option.ActionOption) error { - return fmt.Errorf("SIMClickAtPoint is not supported in ADBDriver, please use UIA2Driver instead") -} - -func (ad *ADBDriver) SIMSwipeWithDirection(direction string, startX, startY, minDistance, maxDistance float64, opts ...option.ActionOption) error { - return fmt.Errorf("SIMSwipeWithDirection is not supported in ADBDriver, please use UIA2Driver instead") -} - -func (ad *ADBDriver) SIMSwipeInArea(direction string, areaStartX, areaStartY, areaEndX, areaEndY, minDistance, maxDistance float64, opts ...option.ActionOption) error { - return fmt.Errorf("SIMSwipeInArea is not supported in ADBDriver, please use UIA2Driver instead") -} - -func (ad *ADBDriver) SIMSwipeFromPointToPoint(startX, startY, endX, endY float64, opts ...option.ActionOption) error { - return fmt.Errorf("SIMSwipeFromPointToPoint is not supported in ADBDriver, please use UIA2Driver instead") -} - -func (ad *ADBDriver) SIMInput(text string, opts ...option.ActionOption) error { - return fmt.Errorf("SIMInput is not supported in ADBDriver, please use UIA2Driver instead") -} diff --git a/uixt/android_driver_uia2.go b/uixt/android_driver_uia2.go index 2997d030..0b3f8810 100644 --- a/uixt/android_driver_uia2.go +++ b/uixt/android_driver_uia2.go @@ -555,10 +555,10 @@ func (ud *UIA2Driver) TouchByEvents(events []types.TouchEvent, opts ...option.Ac // SwipeWithDirection 向指定方向滑动任意距离 // direction: 滑动方向 ("up", "down", "left", "right") -// startX, startY: 起始坐标 -// minDistance, maxDistance: 距离范围,如果相等则为固定距离,否则为随机距离 -func (ud *UIA2Driver) SIMSwipeWithDirection(direction string, startX, startY, minDistance, maxDistance float64, opts ...option.ActionOption) error { - absStartX, absStartY, err := convertToAbsolutePoint(ud, startX, startY) +// 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 } @@ -568,7 +568,7 @@ func (ud *UIA2Driver) SIMSwipeWithDirection(direction string, startX, startY, mi log.Info().Str("direction", direction). Float64("startX", absStartX).Float64("startY", absStartY). - Float64("minDistance", minDistance).Float64("maxDistance", maxDistance). + Float64("minDistance", simMinDistance).Float64("maxDistance", simMaxDistance). Str("deviceModel", deviceModel). Int("deviceID", deviceParams.DeviceID). Float64("pressure", deviceParams.Pressure). @@ -595,7 +595,7 @@ func (ud *UIA2Driver) SIMSwipeWithDirection(direction string, startX, startY, mi // 使用滑动仿真算法生成触摸事件序列 events, err := simulator.GenerateSlideWithRandomDistance( - absStartX, absStartY, slideDirection, minDistance, maxDistance, + absStartX, absStartY, slideDirection, simMinDistance, simMaxDistance, deviceParams.DeviceID, deviceParams.Pressure, deviceParams.Size) if err != nil { return fmt.Errorf("generate slide events failed: %v", err) @@ -607,15 +607,15 @@ func (ud *UIA2Driver) SIMSwipeWithDirection(direction string, startX, startY, mi // SwipeInArea 在指定区域内向指定方向滑动任意距离 // direction: 滑动方向 ("up", "down", "left", "right") -// areaStartX, areaStartY, areaEndX, areaEndY: 区域范围(相对坐标) -// minDistance, maxDistance: 距离范围,如果相等则为固定距离,否则为随机距离 -func (ud *UIA2Driver) SIMSwipeInArea(direction string, areaStartX, areaStartY, areaEndX, areaEndY, minDistance, maxDistance float64, opts ...option.ActionOption) error { +// 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, areaStartX, areaStartY) + absAreaStartX, absAreaStartY, err := convertToAbsolutePoint(ud, simAreaStartX, simAreaStartY) if err != nil { return err } - absAreaEndX, absAreaEndY, err := convertToAbsolutePoint(ud, areaEndX, areaEndY) + absAreaEndX, absAreaEndY, err := convertToAbsolutePoint(ud, simAreaEndX, simAreaEndY) if err != nil { return err } @@ -635,7 +635,7 @@ func (ud *UIA2Driver) SIMSwipeInArea(direction string, areaStartX, areaStartY, a log.Info().Str("direction", direction). Float64("areaStartX", absAreaStartX).Float64("areaStartY", absAreaStartY). Float64("areaEndX", absAreaEndX).Float64("areaEndY", absAreaEndY). - Float64("minDistance", minDistance).Float64("maxDistance", maxDistance). + Float64("minDistance", simMinDistance).Float64("maxDistance", simMaxDistance). Str("deviceModel", deviceModel). Int("deviceID", deviceParams.DeviceID). Float64("pressure", deviceParams.Pressure). @@ -663,7 +663,7 @@ func (ud *UIA2Driver) SIMSwipeInArea(direction string, areaStartX, areaStartY, a // 使用滑动仿真算法生成区域内滑动的触摸事件序列 events, err := simulator.GenerateSlideInArea( absAreaStartX, absAreaStartY, absAreaEndX, absAreaEndY, - slideDirection, minDistance, maxDistance, + slideDirection, simMinDistance, simMaxDistance, deviceParams.DeviceID, deviceParams.Pressure, deviceParams.Size) if err != nil { return fmt.Errorf("generate slide in area events failed: %v", err) @@ -674,15 +674,15 @@ func (ud *UIA2Driver) SIMSwipeInArea(direction string, areaStartX, areaStartY, a } // SwipeFromPointToPoint 指定起始点和结束点进行滑动 -// startX, startY: 起始坐标(相对坐标) -// endX, endY: 结束坐标(相对坐标) -func (ud *UIA2Driver) SIMSwipeFromPointToPoint(startX, startY, endX, endY float64, opts ...option.ActionOption) error { +// fromX, fromY: 起始坐标(相对坐标) +// toX, toY: 结束坐标(相对坐标) +func (ud *UIA2Driver) SIMSwipeFromPointToPoint(fromX, fromY, toX, toY float64, opts ...option.ActionOption) error { // 转换起始点和结束点为绝对坐标 - absStartX, absStartY, err := convertToAbsolutePoint(ud, startX, startY) + absStartX, absStartY, err := convertToAbsolutePoint(ud, fromX, fromY) if err != nil { return err } - absEndX, absEndY, err := convertToAbsolutePoint(ud, endX, endY) + absEndX, absEndY, err := convertToAbsolutePoint(ud, toX, toY) if err != nil { return err } diff --git a/uixt/driver.go b/uixt/driver.go index 81a871e5..dc21be00 100644 --- a/uixt/driver.go +++ b/uixt/driver.go @@ -18,7 +18,6 @@ var ( // Ensure drivers implement SIMSupport interface _ SIMSupport = (*UIA2Driver)(nil) - _ SIMSupport = (*ADBDriver)(nil) ) // current implemeted driver: ADBDriver, UIA2Driver, WDADriver, HDCDriver @@ -99,8 +98,8 @@ type IDriver interface { // 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, startX, startY, minDistance, maxDistance float64, opts ...option.ActionOption) error - SIMSwipeInArea(direction string, areaStartX, areaStartY, areaEndX, areaEndY, minDistance, maxDistance float64, opts ...option.ActionOption) error - SIMSwipeFromPointToPoint(startX, startY, endX, endY 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 } diff --git a/uixt/mcp_tools_swipe.go b/uixt/mcp_tools_swipe.go index a889ae7f..23e6fd46 100644 --- a/uixt/mcp_tools_swipe.go +++ b/uixt/mcp_tools_swipe.go @@ -601,31 +601,31 @@ func (t *ToolSIMSwipeDirection) Implement() server.ToolHandlerFunc { direction, validDirections) } - // Default values if not provided - startX := unifiedReq.StartX - startY := unifiedReq.StartY - minDistance := unifiedReq.MinDistance - maxDistance := unifiedReq.MaxDistance + // 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 startX == 0 { - startX = 0.5 // default to center + if fromX == 0 { + fromX = 0.5 // default to center } - if startY == 0 { - startY = 0.5 // default to center + if fromY == 0 { + fromY = 0.5 // default to center } - if minDistance == 0 { - minDistance = 100 // default minimum distance + if simMinDistance == 0 { + simMinDistance = 100 // default minimum distance } - if maxDistance == 0 { - maxDistance = 300 // default maximum distance + if simMaxDistance == 0 { + simMaxDistance = 300 // default maximum distance } log.Info(). Str("direction", direction). - Float64("startX", startX). - Float64("startY", startY). - Float64("minDistance", minDistance). - Float64("maxDistance", maxDistance). + Float64("startX", fromX). + Float64("startY", fromY). + Float64("minDistance", simMinDistance). + Float64("maxDistance", simMaxDistance). Msg("performing simulated swipe with direction") // Build all options from request arguments @@ -633,7 +633,7 @@ func (t *ToolSIMSwipeDirection) Implement() server.ToolHandlerFunc { // Call the underlying SIMSwipeWithDirection method (check if driver supports SIM) if simDriver, ok := driverExt.IDriver.(SIMSupport); ok { - err = simDriver.SIMSwipeWithDirection(direction, startX, startY, minDistance, maxDistance, opts...) + err = simDriver.SIMSwipeWithDirection(direction, fromX, fromY, simMinDistance, simMaxDistance, opts...) if err != nil { return NewMCPErrorResponse(fmt.Sprintf("Simulated swipe failed: %s", err.Error())), err } @@ -642,19 +642,19 @@ func (t *ToolSIMSwipeDirection) Implement() server.ToolHandlerFunc { } // Calculate actual distance for response (approximate) - actualDistance := minDistance - if maxDistance > minDistance { - actualDistance = minDistance + (maxDistance-minDistance)*0.5 // approximate middle value + 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, startX, startY, actualDistance) + direction, fromX, fromY, actualDistance) returnData := ToolSIMSwipeDirection{ Direction: direction, - StartX: startX, - StartY: startY, - MinDistance: minDistance, - MaxDistance: maxDistance, + StartX: fromX, + StartY: fromY, + MinDistance: simMinDistance, + MaxDistance: simMaxDistance, ActualDistance: actualDistance, } @@ -672,18 +672,18 @@ func (t *ToolSIMSwipeDirection) ConvertActionToCallToolRequest(action option.Mob arguments["direction"] = direction } - // Extract coordinates and distances - if startX, exists := paramsMap["start_x"]; exists { - arguments["start_x"] = startX + // Extract coordinates and distances - use new field names directly + if fromX, exists := paramsMap["from_x"]; exists { + arguments["from_x"] = fromX } - if startY, exists := paramsMap["start_y"]; exists { - arguments["start_y"] = startY + if fromY, exists := paramsMap["from_y"]; exists { + arguments["from_y"] = fromY } - if minDistance, exists := paramsMap["min_distance"]; exists { - arguments["min_distance"] = minDistance + if minDistance, exists := paramsMap["sim_min_distance"]; exists { + arguments["sim_min_distance"] = minDistance } - if maxDistance, exists := paramsMap["max_distance"]; exists { - arguments["max_distance"] = maxDistance + if maxDistance, exists := paramsMap["sim_max_distance"]; exists { + arguments["sim_max_distance"] = maxDistance } // Add duration and press duration from options @@ -753,30 +753,30 @@ func (t *ToolSIMSwipeInArea) Implement() server.ToolHandlerFunc { direction, validDirections) } - // Get area coordinates - areaStartX := unifiedReq.AreaStartX - areaStartY := unifiedReq.AreaStartY - areaEndX := unifiedReq.AreaEndX - areaEndY := unifiedReq.AreaEndY - minDistance := unifiedReq.MinDistance - maxDistance := unifiedReq.MaxDistance + // 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 minDistance == 0 { - minDistance = 100 + if simMinDistance == 0 { + simMinDistance = 100 } - if maxDistance == 0 { - maxDistance = 300 + if simMaxDistance == 0 { + simMaxDistance = 300 } log.Info(). Str("direction", direction). - Float64("areaStartX", areaStartX). - Float64("areaStartY", areaStartY). - Float64("areaEndX", areaEndX). - Float64("areaEndY", areaEndY). - Float64("minDistance", minDistance). - Float64("maxDistance", maxDistance). + 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 @@ -784,7 +784,7 @@ func (t *ToolSIMSwipeInArea) Implement() server.ToolHandlerFunc { // Call the underlying SIMSwipeInArea method (check if driver supports SIM) if simDriver, ok := driverExt.IDriver.(SIMSupport); ok { - err = simDriver.SIMSwipeInArea(direction, areaStartX, areaStartY, areaEndX, areaEndY, minDistance, maxDistance, opts...) + 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 } @@ -793,15 +793,15 @@ func (t *ToolSIMSwipeInArea) Implement() server.ToolHandlerFunc { } message := fmt.Sprintf("Successfully performed simulated swipe %s in area (%.2f,%.2f)-(%.2f,%.2f)", - direction, areaStartX, areaStartY, areaEndX, areaEndY) + direction, simAreaStartX, simAreaStartY, simAreaEndX, simAreaEndY) returnData := ToolSIMSwipeInArea{ Direction: direction, - AreaStartX: areaStartX, - AreaStartY: areaStartY, - AreaEndX: areaEndX, - AreaEndY: areaEndY, - MinDistance: minDistance, - MaxDistance: maxDistance, + AreaStartX: simAreaStartX, + AreaStartY: simAreaStartY, + AreaEndX: simAreaEndX, + AreaEndY: simAreaEndY, + MinDistance: simMinDistance, + MaxDistance: simMaxDistance, } return NewMCPSuccessResponse(message, &returnData), nil @@ -818,24 +818,24 @@ func (t *ToolSIMSwipeInArea) ConvertActionToCallToolRequest(action option.Mobile arguments["direction"] = direction } - // Extract area coordinates and distances - if areaStartX, exists := paramsMap["area_start_x"]; exists { - arguments["area_start_x"] = areaStartX + // 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["area_start_y"]; exists { - arguments["area_start_y"] = areaStartY + if areaStartY, exists := paramsMap["sim_area_start_y"]; exists { + arguments["sim_area_start_y"] = areaStartY } - if areaEndX, exists := paramsMap["area_end_x"]; exists { - arguments["area_end_x"] = areaEndX + if areaEndX, exists := paramsMap["sim_area_end_x"]; exists { + arguments["sim_area_end_x"] = areaEndX } - if areaEndY, exists := paramsMap["area_end_y"]; exists { - arguments["area_end_y"] = areaEndY + if areaEndY, exists := paramsMap["sim_area_end_y"]; exists { + arguments["sim_area_end_y"] = areaEndY } - if minDistance, exists := paramsMap["min_distance"]; exists { - arguments["min_distance"] = minDistance + if minDistance, exists := paramsMap["sim_min_distance"]; exists { + arguments["sim_min_distance"] = minDistance } - if maxDistance, exists := paramsMap["max_distance"]; exists { - arguments["max_distance"] = maxDistance + if maxDistance, exists := paramsMap["sim_max_distance"]; exists { + arguments["sim_max_distance"] = maxDistance } // Add duration and press duration from options @@ -886,17 +886,17 @@ func (t *ToolSIMSwipeFromPointToPoint) Implement() server.ToolHandlerFunc { return nil, err } - // Get coordinates from arguments - startX := unifiedReq.StartX - startY := unifiedReq.StartY - endX := unifiedReq.ToX // Using existing ToX field - endY := unifiedReq.ToY // Using existing ToY field + // 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", startX). - Float64("startY", startY). - Float64("endX", endX). - Float64("endY", endY). + 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 @@ -904,7 +904,7 @@ func (t *ToolSIMSwipeFromPointToPoint) Implement() server.ToolHandlerFunc { // Call the underlying SIMSwipeFromPointToPoint method (check if driver supports SIM) if simDriver, ok := driverExt.IDriver.(SIMSupport); ok { - err = simDriver.SIMSwipeFromPointToPoint(startX, startY, endX, endY, opts...) + 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 } @@ -913,12 +913,12 @@ func (t *ToolSIMSwipeFromPointToPoint) Implement() server.ToolHandlerFunc { } message := fmt.Sprintf("Successfully performed simulated swipe from (%.2f,%.2f) to (%.2f,%.2f)", - startX, startY, endX, endY) + fromX, fromY, toX, toY) returnData := ToolSIMSwipeFromPointToPoint{ - StartX: startX, - StartY: startY, - EndX: endX, - EndY: endY, + StartX: fromX, + StartY: fromY, + EndX: toX, + EndY: toY, } return NewMCPSuccessResponse(message, &returnData), nil @@ -930,18 +930,18 @@ func (t *ToolSIMSwipeFromPointToPoint) ConvertActionToCallToolRequest(action opt if paramsMap, ok := action.Params.(map[string]interface{}); ok { arguments := map[string]any{} - // Extract coordinates - if startX, exists := paramsMap["start_x"]; exists { - arguments["start_x"] = startX + // Extract coordinates - use new field names directly + if fromX, exists := paramsMap["from_x"]; exists { + arguments["from_x"] = fromX } - if startY, exists := paramsMap["start_y"]; exists { - arguments["start_y"] = startY + if fromY, exists := paramsMap["from_y"]; exists { + arguments["from_y"] = fromY } - if endX, exists := paramsMap["end_x"]; exists { - arguments["to_x"] = endX // Map to existing ToX field + if toX, exists := paramsMap["to_x"]; exists { + arguments["to_x"] = toX } - if endY, exists := paramsMap["end_y"]; exists { - arguments["to_y"] = endY // Map to existing ToY field + if toY, exists := paramsMap["to_y"]; exists { + arguments["to_y"] = toY } // Add duration and press duration from options diff --git a/uixt/option/action.go b/uixt/option/action.go index f165cf36..7dfd872c 100644 --- a/uixt/option/action.go +++ b/uixt/option/action.go @@ -206,17 +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"` - StartX float64 `json:"start_x,omitempty" yaml:"start_x,omitempty" desc:"Starting X coordinate for simulated swipe"` - StartY float64 `json:"start_y,omitempty" yaml:"start_y,omitempty" desc:"Starting Y coordinate for simulated swipe"` - MinDistance float64 `json:"min_distance,omitempty" yaml:"min_distance,omitempty" desc:"Minimum distance for simulated swipe"` - MaxDistance float64 `json:"max_distance,omitempty" yaml:"max_distance,omitempty" desc:"Maximum distance for simulated swipe"` - AreaStartX float64 `json:"area_start_x,omitempty" yaml:"area_start_x,omitempty" desc:"Area starting X coordinate for simulated swipe"` - AreaStartY float64 `json:"area_start_y,omitempty" yaml:"area_start_y,omitempty" desc:"Area starting Y coordinate for simulated swipe"` - AreaEndX float64 `json:"area_end_x,omitempty" yaml:"area_end_x,omitempty" desc:"Area ending X coordinate for simulated swipe"` - AreaEndY float64 `json:"area_end_y,omitempty" yaml:"area_end_y,omitempty" desc:"Area ending Y coordinate for 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"` + + // 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 @@ -662,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]