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] 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.