mirror of
https://github.com/httprunner/httprunner.git
synced 2026-09-08 17:09:33 +08:00
feat: mark tap/swipe UI operation
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
### UI操作标注
|
||||||
|
|
||||||
|
针对 UI 操作的位置进行标注,帮助用户直观地了解操作发生的位置。
|
||||||
|
|
||||||
|
#### 功能说明
|
||||||
|
|
||||||
|
- 点击操作(tap):使用红色矩形框标注点击位置
|
||||||
|
- 滑动操作(swipe):使用红色箭头标注滑动方向,从起始点指向结束点
|
||||||
|
|
||||||
|
#### 使用方法
|
||||||
|
|
||||||
|
只需在操作函数中添加 `WithMarkOperationEnabled(true)` 选项即可启用操作标注功能:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 启用操作标注功能
|
||||||
|
opts := []option.ActionOption{option.WithMarkOperationEnabled(true)}
|
||||||
|
|
||||||
|
// 执行点击操作,会自动用红色矩形标注点击位置
|
||||||
|
err := driver.TapXY(0.5, 0.5, opts...)
|
||||||
|
|
||||||
|
// 执行滑动操作,会自动用红色箭头标注滑动方向
|
||||||
|
err = driver.Swipe(0.2, 0.5, 0.8, 0.5, opts...)
|
||||||
|
|
||||||
|
// 可以同时使用其他选项
|
||||||
|
opts = append(opts, option.WithScreenShotFileName("custom_name"))
|
||||||
|
err = driver.TapXY(0.3, 0.7, opts...)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 标注结果
|
||||||
|
|
||||||
|
标注后的图片会保存在截图目录中,文件名格式为:`{timestamp}_{tap|swipe}_marked.png`
|
||||||
@@ -1 +1 @@
|
|||||||
v5.0.0-beta-2504301621
|
v5.0.0-beta-2505051631
|
||||||
|
|||||||
@@ -313,8 +313,15 @@ func (ad *ADBDriver) TapAbsXY(x, y float64, opts ...option.ActionOption) error {
|
|||||||
// adb shell input tap x y
|
// adb shell input tap x y
|
||||||
xStr := fmt.Sprintf("%.1f", x)
|
xStr := fmt.Sprintf("%.1f", x)
|
||||||
yStr := fmt.Sprintf("%.1f", y)
|
yStr := fmt.Sprintf("%.1f", y)
|
||||||
_, err := ad.runShellCommand(
|
_, err := ad.runShellCommand("input", "tap", xStr, yStr)
|
||||||
"input", "tap", xStr, yStr)
|
|
||||||
|
// mark UI operation
|
||||||
|
if actionOptions.MarkOperationEnabled {
|
||||||
|
if markErr := MarkUIOperation(ad, ACTION_TapAbsXY, []float64{x, y}); markErr != nil {
|
||||||
|
log.Warn().Err(markErr).Msg("Failed to mark tap operation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, fmt.Sprintf("tap <%s, %s> failed", xStr, yStr))
|
return errors.Wrap(err, fmt.Sprintf("tap <%s, %s> failed", xStr, yStr))
|
||||||
}
|
}
|
||||||
@@ -394,6 +401,14 @@ func (ad *ADBDriver) Drag(fromX, fromY, toX, toY float64, opts ...option.ActionO
|
|||||||
fmt.Sprintf("%.1f", toX), fmt.Sprintf("%.1f", toY),
|
fmt.Sprintf("%.1f", toX), fmt.Sprintf("%.1f", toY),
|
||||||
fmt.Sprintf("%d", int(duration)),
|
fmt.Sprintf("%d", int(duration)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// mark UI operation
|
||||||
|
if actionOptions.MarkOperationEnabled {
|
||||||
|
if markErr := MarkUIOperation(ad, ACTION_Drag, []float64{fromX, fromY, toX, toY}); markErr != nil {
|
||||||
|
log.Warn().Err(markErr).Msg("Failed to mark drag operation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "adb drag failed")
|
return errors.Wrap(err, "adb drag failed")
|
||||||
}
|
}
|
||||||
@@ -417,6 +432,14 @@ func (ad *ADBDriver) Swipe(fromX, fromY, toX, toY float64, opts ...option.Action
|
|||||||
fmt.Sprintf("%.1f", fromX), fmt.Sprintf("%.1f", fromY),
|
fmt.Sprintf("%.1f", fromX), fmt.Sprintf("%.1f", fromY),
|
||||||
fmt.Sprintf("%.1f", toX), fmt.Sprintf("%.1f", toY),
|
fmt.Sprintf("%.1f", toX), fmt.Sprintf("%.1f", toY),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// mark UI operation
|
||||||
|
if actionOptions.MarkOperationEnabled {
|
||||||
|
if markErr := MarkUIOperation(ad, ACTION_Swipe, []float64{fromX, fromY, toX, toY}); markErr != nil {
|
||||||
|
log.Warn().Err(markErr).Msg("Failed to mark swipe operation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "adb swipe failed")
|
return errors.Wrap(err, "adb swipe failed")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -324,6 +324,14 @@ func (ud *UIA2Driver) TapAbsXY(x, y float64, opts ...option.ActionOption) error
|
|||||||
|
|
||||||
urlStr := fmt.Sprintf("/session/%s/actions/tap", ud.Session.ID)
|
urlStr := fmt.Sprintf("/session/%s/actions/tap", ud.Session.ID)
|
||||||
_, err := ud.Session.POST(data, urlStr)
|
_, err := ud.Session.POST(data, urlStr)
|
||||||
|
|
||||||
|
// mark UI operation
|
||||||
|
if actionOptions.MarkOperationEnabled {
|
||||||
|
if markErr := MarkUIOperation(ud, ACTION_TapAbsXY, []float64{x, y}); markErr != nil {
|
||||||
|
log.Warn().Err(markErr).Msg("Failed to mark tap operation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,6 +382,14 @@ func (ud *UIA2Driver) Drag(fromX, fromY, toX, toY float64, opts ...option.Action
|
|||||||
// register(postHandler, new Drag("/wd/hub/session/:sessionId/touch/drag"))
|
// register(postHandler, new Drag("/wd/hub/session/:sessionId/touch/drag"))
|
||||||
urlStr := fmt.Sprintf("/session/%s/touch/drag", ud.Session.ID)
|
urlStr := fmt.Sprintf("/session/%s/touch/drag", ud.Session.ID)
|
||||||
_, err = ud.Session.POST(data, urlStr)
|
_, err = ud.Session.POST(data, urlStr)
|
||||||
|
|
||||||
|
// mark UI operation
|
||||||
|
if actionOptions.MarkOperationEnabled {
|
||||||
|
if markErr := MarkUIOperation(ud, ACTION_Drag, []float64{fromX, fromY, toX, toY}); markErr != nil {
|
||||||
|
log.Warn().Err(markErr).Msg("Failed to mark drag operation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +433,14 @@ func (ud *UIA2Driver) Swipe(fromX, fromY, toX, toY float64, opts ...option.Actio
|
|||||||
|
|
||||||
urlStr := fmt.Sprintf("/session/%s/actions/swipe", ud.Session.ID)
|
urlStr := fmt.Sprintf("/session/%s/actions/swipe", ud.Session.ID)
|
||||||
_, err = ud.Session.POST(data, urlStr)
|
_, err = ud.Session.POST(data, urlStr)
|
||||||
|
|
||||||
|
// mark UI operation
|
||||||
|
if actionOptions.MarkOperationEnabled {
|
||||||
|
if markErr := MarkUIOperation(ud, ACTION_Swipe, []float64{fromX, fromY, toX, toY}); markErr != nil {
|
||||||
|
log.Warn().Err(markErr).Msg("Failed to mark swipe operation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,14 @@ func (wd *BrowserDriver) Drag(fromX, fromY, toX, toY float64, options ...option.
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err = wd.HttpPOST(data, wd.sessionId, "ui/drag")
|
_, err = wd.HttpPOST(data, wd.sessionId, "ui/drag")
|
||||||
|
|
||||||
|
// mark UI operation
|
||||||
|
if actionOptions.MarkOperationEnabled {
|
||||||
|
if markErr := MarkUIOperation(wd, ACTION_Drag, []float64{fromX, fromY, toX, toY}); markErr != nil {
|
||||||
|
log.Warn().Err(markErr).Msg("Failed to mark drag operation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,6 +537,14 @@ func (wd *BrowserDriver) TapFloat(x, y float64, options ...option.ActionOption)
|
|||||||
"duration": duration,
|
"duration": duration,
|
||||||
}
|
}
|
||||||
_, err := wd.HttpPOST(data, wd.sessionId, "ui/tap")
|
_, err := wd.HttpPOST(data, wd.sessionId, "ui/tap")
|
||||||
|
|
||||||
|
// mark UI operation
|
||||||
|
if actionOptions.MarkOperationEnabled {
|
||||||
|
if markErr := MarkUIOperation(wd, ACTION_TapAbsXY, []float64{x, y}); markErr != nil {
|
||||||
|
log.Warn().Err(markErr).Msg("Failed to mark tap operation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const (
|
|||||||
ACTION_TapByCV ActionMethod = "tap_cv"
|
ACTION_TapByCV ActionMethod = "tap_cv"
|
||||||
ACTION_DoubleTapXY ActionMethod = "double_tap_xy"
|
ACTION_DoubleTapXY ActionMethod = "double_tap_xy"
|
||||||
ACTION_Swipe ActionMethod = "swipe"
|
ACTION_Swipe ActionMethod = "swipe"
|
||||||
|
ACTION_Drag ActionMethod = "drag"
|
||||||
ACTION_Input ActionMethod = "input"
|
ACTION_Input ActionMethod = "input"
|
||||||
ACTION_Back ActionMethod = "back"
|
ACTION_Back ActionMethod = "back"
|
||||||
ACTION_KeyCode ActionMethod = "keycode"
|
ACTION_KeyCode ActionMethod = "keycode"
|
||||||
|
|||||||
+229
-55
@@ -2,7 +2,6 @@ package uixt
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
@@ -10,6 +9,7 @@ import (
|
|||||||
"image/gif"
|
"image/gif"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -298,69 +298,243 @@ func compressImageBuffer(raw *bytes.Buffer) (compressed *bytes.Buffer, err error
|
|||||||
return &buf, nil
|
return &buf, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SavePositionImg saves an image with position markers
|
// MarkUIOperation add operation mark for UI operation
|
||||||
func SavePositionImg(params struct {
|
func MarkUIOperation(driver IDriver, actionType ActionMethod, actionCoordinates []float64) error {
|
||||||
InputImgBase64 string
|
if actionType == "" || len(actionCoordinates) == 0 {
|
||||||
Rect struct {
|
return nil
|
||||||
X float64
|
|
||||||
Y float64
|
|
||||||
}
|
|
||||||
OutputPath string
|
|
||||||
}) error {
|
|
||||||
// 解码Base64图像
|
|
||||||
imgData := params.InputImgBase64
|
|
||||||
// 如果包含了数据URL前缀,去掉它
|
|
||||||
if strings.HasPrefix(imgData, "data:image/") {
|
|
||||||
parts := strings.Split(imgData, ",")
|
|
||||||
if len(parts) > 1 {
|
|
||||||
imgData = parts[1]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解码Base64
|
// get screenshot
|
||||||
unbased, err := base64.StdEncoding.DecodeString(imgData)
|
compressedBufSource, err := driver.ScreenShot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("无法解码Base64图像: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解码图像
|
// create screenshot save path
|
||||||
reader := bytes.NewReader(unbased)
|
timestamp := builtin.GenNameWithTimestamp("%d")
|
||||||
img, _, err := image.Decode(reader)
|
var imagePath string
|
||||||
|
|
||||||
|
if actionType == ACTION_TapAbsXY {
|
||||||
|
if len(actionCoordinates) != 2 {
|
||||||
|
return fmt.Errorf("invalid tap action coordinates: %v", actionCoordinates)
|
||||||
|
}
|
||||||
|
imagePath = filepath.Join(
|
||||||
|
config.GetConfig().ScreenShotsPath,
|
||||||
|
fmt.Sprintf("%s_tap_marked.png", timestamp),
|
||||||
|
)
|
||||||
|
x, y := actionCoordinates[0], actionCoordinates[1]
|
||||||
|
point := image.Point{X: int(x), Y: int(y)}
|
||||||
|
err = SaveImageWithCircleMarker(compressedBufSource, point, imagePath)
|
||||||
|
} else if actionType == ACTION_Swipe {
|
||||||
|
if len(actionCoordinates) != 4 {
|
||||||
|
return fmt.Errorf("invalid swipe action coordinates: %v", actionCoordinates)
|
||||||
|
}
|
||||||
|
imagePath = filepath.Join(
|
||||||
|
config.GetConfig().ScreenShotsPath,
|
||||||
|
fmt.Sprintf("%s_swipe_marked.png", timestamp),
|
||||||
|
)
|
||||||
|
fromX, fromY := actionCoordinates[0], actionCoordinates[1]
|
||||||
|
toX, toY := actionCoordinates[2], actionCoordinates[3]
|
||||||
|
from := image.Point{X: int(fromX), Y: int(fromY)}
|
||||||
|
to := image.Point{X: int(toX), Y: int(toY)}
|
||||||
|
err = SaveImageWithArrowMarker(compressedBufSource, from, to, imagePath)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("无法解码图像数据: %w", err)
|
log.Error().Err(err).Msg("mark UI operation failed")
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建一个可以在其上绘制的图像
|
if imagePath != "" {
|
||||||
bounds := img.Bounds()
|
log.Info().Str("operation", string(actionType)).
|
||||||
rgba := image.NewRGBA(bounds)
|
Str("imagePath", imagePath).
|
||||||
draw.Draw(rgba, bounds, img, bounds.Min, draw.Src)
|
Msg("mark UI operation success")
|
||||||
|
|
||||||
// 在点击/拖动位置绘制标记
|
|
||||||
markRadius := 30
|
|
||||||
x, y := int(params.Rect.X), int(params.Rect.Y)
|
|
||||||
|
|
||||||
// 绘制红色圆圈
|
|
||||||
for i := -markRadius; i <= markRadius; i++ {
|
|
||||||
for j := -markRadius; j <= markRadius; j++ {
|
|
||||||
if i*i+j*j <= markRadius*markRadius {
|
|
||||||
if x+i >= 0 && x+i < bounds.Max.X && y+j >= 0 && y+j < bounds.Max.Y {
|
|
||||||
rgba.Set(x+i, y+j, color.RGBA{255, 0, 0, 255})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存图像
|
|
||||||
outFile, err := os.Create(params.OutputPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("无法创建输出文件: %w", err)
|
|
||||||
}
|
|
||||||
defer outFile.Close()
|
|
||||||
|
|
||||||
// 编码为PNG并保存
|
|
||||||
if err := png.Encode(outFile, rgba); err != nil {
|
|
||||||
return fmt.Errorf("无法编码和保存图像: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveImageWithCircleMarker saves an image with circle marker
|
||||||
|
func SaveImageWithCircleMarker(imgBuf *bytes.Buffer, point image.Point, outputPath string) error {
|
||||||
|
img, _, err := image.Decode(imgBuf)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decode image data: %w", err)
|
||||||
|
}
|
||||||
|
bounds := img.Bounds()
|
||||||
|
rgba := image.NewRGBA(bounds)
|
||||||
|
draw.Draw(rgba, bounds, img, bounds.Min, draw.Src)
|
||||||
|
|
||||||
|
// draw a red circle at the tap point
|
||||||
|
centerX := point.X
|
||||||
|
centerY := point.Y
|
||||||
|
radius := 20
|
||||||
|
lineWidth := 5
|
||||||
|
red := color.RGBA{255, 0, 0, 255}
|
||||||
|
|
||||||
|
for angle := 0.0; angle < 2*math.Pi; angle += 0.01 {
|
||||||
|
for w := 0; w < lineWidth; w++ {
|
||||||
|
r := float64(radius - w)
|
||||||
|
x := int(float64(centerX) + r*math.Cos(angle))
|
||||||
|
y := int(float64(centerY) + r*math.Sin(angle))
|
||||||
|
if x >= 0 && x < bounds.Max.X && y >= 0 && y < bounds.Max.Y {
|
||||||
|
rgba.Set(x, y, red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outFile, err := os.Create(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
|
}
|
||||||
|
defer outFile.Close()
|
||||||
|
if err := png.Encode(outFile, rgba); err != nil {
|
||||||
|
return fmt.Errorf("failed to encode and save image: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveImageWithArrowMarker saves an image with an arrow marker
|
||||||
|
func SaveImageWithArrowMarker(imgBuf *bytes.Buffer, from, to image.Point, outputPath string) error {
|
||||||
|
img, _, err := image.Decode(imgBuf)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decode image data: %w", err)
|
||||||
|
}
|
||||||
|
bounds := img.Bounds()
|
||||||
|
rgba := image.NewRGBA(bounds)
|
||||||
|
draw.Draw(rgba, bounds, img, bounds.Min, draw.Src)
|
||||||
|
drawArrow(rgba, from, to, color.RGBA{255, 0, 0, 255}, 5)
|
||||||
|
outFile, err := os.Create(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
|
}
|
||||||
|
defer outFile.Close()
|
||||||
|
if err := png.Encode(outFile, rgba); err != nil {
|
||||||
|
return fmt.Errorf("failed to encode and save image: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawArrow draws an arrow from 'from' to 'to' on the image
|
||||||
|
func drawArrow(rgba *image.RGBA, from, to image.Point, color color.RGBA, lineWidth int) {
|
||||||
|
bounds := rgba.Bounds()
|
||||||
|
dx, dy := to.X-from.X, to.Y-from.Y
|
||||||
|
steps := int(math.Sqrt(float64(dx*dx + dy*dy)))
|
||||||
|
if steps == 0 {
|
||||||
|
steps = 1
|
||||||
|
}
|
||||||
|
stepX, stepY := float64(dx)/float64(steps), float64(dy)/float64(steps)
|
||||||
|
// main line
|
||||||
|
for i := 0; i < steps; i++ {
|
||||||
|
x := int(float64(from.X) + stepX*float64(i))
|
||||||
|
y := int(float64(from.Y) + stepY*float64(i))
|
||||||
|
for w := 0; w < lineWidth; w++ {
|
||||||
|
offsetX, offsetY := 0, 0
|
||||||
|
if math.Abs(stepX) > math.Abs(stepY) {
|
||||||
|
offsetY = w - lineWidth/2
|
||||||
|
} else {
|
||||||
|
offsetX = w - lineWidth/2
|
||||||
|
}
|
||||||
|
drawX, drawY := x+offsetX, y+offsetY
|
||||||
|
if drawX >= 0 && drawX < bounds.Max.X && drawY >= 0 && drawY < bounds.Max.Y {
|
||||||
|
rgba.Set(drawX, drawY, color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// arrow head
|
||||||
|
arrowLength := float64(steps) * 0.15
|
||||||
|
if arrowLength < 10 {
|
||||||
|
arrowLength = 10
|
||||||
|
} else if arrowLength > 30 {
|
||||||
|
arrowLength = 30
|
||||||
|
}
|
||||||
|
head := calculateArrowHead(float64(from.X), float64(from.Y), float64(to.X), float64(to.Y), arrowLength)
|
||||||
|
if head != nil {
|
||||||
|
for _, point := range head[:2] {
|
||||||
|
drawLineInImage(rgba, to.X, to.Y, int(point.X), int(point.Y), color, lineWidth, bounds)
|
||||||
|
}
|
||||||
|
for _, point := range head[1:] {
|
||||||
|
drawLineInImage(rgba, to.X, to.Y, int(point.X), int(point.Y), color, lineWidth, bounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateArrowHead calculates the endpoint and arrowhead coordinates
|
||||||
|
func calculateArrowHead(fromX, fromY, toX, toY float64, arrowLength float64) []struct{ X, Y float64 } {
|
||||||
|
// calculate direction vector
|
||||||
|
dx, dy := toX-fromX, toY-fromY
|
||||||
|
// calculate distance
|
||||||
|
length := math.Sqrt(dx*dx + dy*dy)
|
||||||
|
if length < 1e-6 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unit vector
|
||||||
|
dx, dy = dx/length, dy/length
|
||||||
|
|
||||||
|
// calculate orthogonal vector of arrow direction (counterclockwise 90 degrees)
|
||||||
|
orthX, orthY := -dy, dx
|
||||||
|
|
||||||
|
// calculate two wing points of arrow
|
||||||
|
headWidth := arrowLength * 0.5
|
||||||
|
backX, backY := toX-dx*arrowLength, toY-dy*arrowLength
|
||||||
|
|
||||||
|
// two wing points of arrow
|
||||||
|
leftWingX, leftWingY := backX+orthX*headWidth, backY+orthY*headWidth
|
||||||
|
rightWingX, rightWingY := backX-orthX*headWidth, backY-orthY*headWidth
|
||||||
|
|
||||||
|
return []struct{ X, Y float64 }{
|
||||||
|
{leftWingX, leftWingY},
|
||||||
|
{toX, toY},
|
||||||
|
{rightWingX, rightWingY},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawLineInImage draws a line on the image
|
||||||
|
func drawLineInImage(img *image.RGBA, x0, y0, x1, y1 int, lineColor color.RGBA, lineWidth int, bounds image.Rectangle) {
|
||||||
|
// use Bresenham algorithm to draw line
|
||||||
|
dx, dy := math.Abs(float64(x1-x0)), math.Abs(float64(y1-y0))
|
||||||
|
sx, sy := 1, 1
|
||||||
|
if x0 >= x1 {
|
||||||
|
sx = -1
|
||||||
|
}
|
||||||
|
if y0 >= y1 {
|
||||||
|
sy = -1
|
||||||
|
}
|
||||||
|
err := dx - dy
|
||||||
|
|
||||||
|
for {
|
||||||
|
// draw point (consider line width)
|
||||||
|
for w := 0; w < lineWidth; w++ {
|
||||||
|
offsetX, offsetY := 0, 0
|
||||||
|
|
||||||
|
// decide offset direction based on line angle
|
||||||
|
if dx > dy {
|
||||||
|
// more horizontal line
|
||||||
|
offsetY = w - lineWidth/2
|
||||||
|
} else {
|
||||||
|
// more vertical line
|
||||||
|
offsetX = w - lineWidth/2
|
||||||
|
}
|
||||||
|
|
||||||
|
drawX, drawY := x0+offsetX, y0+offsetY
|
||||||
|
if drawX >= 0 && drawX < bounds.Max.X && drawY >= 0 && drawY < bounds.Max.Y {
|
||||||
|
img.Set(drawX, drawY, lineColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// end of line
|
||||||
|
if x0 == x1 && y0 == y1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculate next point
|
||||||
|
e2 := 2 * err
|
||||||
|
if e2 > -dy {
|
||||||
|
err = err - dy
|
||||||
|
x0 = x0 + sx
|
||||||
|
}
|
||||||
|
if e2 < dx {
|
||||||
|
err = err + dx
|
||||||
|
y0 = y0 + sy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+42
-23
@@ -3,11 +3,12 @@
|
|||||||
package uixt
|
package uixt
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/httprunner/httprunner/v5/internal/builtin"
|
|
||||||
"github.com/httprunner/httprunner/v5/uixt/option"
|
"github.com/httprunner/httprunner/v5/uixt/option"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -254,32 +255,50 @@ func TestDriverExt_Action_Offset(t *testing.T) {
|
|||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSavePositionImg(t *testing.T) {
|
func TestSaveImageWithCircle(t *testing.T) {
|
||||||
imageBase64, _, err := builtin.LoadImage("ai/testdata/popup_risk_warning.png")
|
imgBytes, err := os.ReadFile("ai/testdata/llk_1.png")
|
||||||
|
require.NoError(t, err)
|
||||||
|
imgBuf := bytes.NewBuffer(imgBytes)
|
||||||
|
|
||||||
|
point := image.Point{X: 500, Y: 500}
|
||||||
|
outputPath := "ai/testdata/output.png"
|
||||||
|
|
||||||
|
err = SaveImageWithCircleMarker(imgBuf, point, outputPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
params := struct {
|
defer os.Remove(outputPath)
|
||||||
InputImgBase64 string
|
|
||||||
Rect struct {
|
|
||||||
X float64
|
|
||||||
Y float64
|
|
||||||
}
|
|
||||||
OutputPath string
|
|
||||||
}{
|
|
||||||
InputImgBase64: imageBase64,
|
|
||||||
Rect: struct {
|
|
||||||
X float64
|
|
||||||
Y float64
|
|
||||||
}{
|
|
||||||
X: 500,
|
|
||||||
Y: 500,
|
|
||||||
},
|
|
||||||
OutputPath: "ai/testdata/output.png",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = SavePositionImg(params)
|
func TestSaveImageWithArrow(t *testing.T) {
|
||||||
|
imgBytes, err := os.ReadFile("ai/testdata/llk_1.png")
|
||||||
|
require.NoError(t, err)
|
||||||
|
imgBuf := bytes.NewBuffer(imgBytes)
|
||||||
|
|
||||||
|
from := image.Point{X: 500, Y: 500}
|
||||||
|
to := image.Point{X: 1000, Y: 1000}
|
||||||
|
outputPath := "ai/testdata/output.png"
|
||||||
|
|
||||||
|
err = SaveImageWithArrowMarker(imgBuf, from, to, outputPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// cleanup
|
defer os.Remove(outputPath)
|
||||||
defer os.Remove(params.OutputPath)
|
}
|
||||||
|
|
||||||
|
func TestMarkOperation(t *testing.T) {
|
||||||
|
driver := setupDriverExt(t)
|
||||||
|
|
||||||
|
opts := []option.ActionOption{option.WithMarkOperationEnabled(true)}
|
||||||
|
|
||||||
|
// tap point
|
||||||
|
err := driver.TapXY(0.5, 0.5, opts...)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
err = driver.TapAbsXY(500, 800, opts...)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
// swipe
|
||||||
|
err = driver.Swipe(0.2, 0.5, 0.8, 0.5, opts...)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
err = driver.Swipe(0.3, 0.7, 0.3, 0.3, opts...)
|
||||||
|
assert.Nil(t, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,8 +161,17 @@ func (hd *HDCDriver) TapAbsXY(x, y float64, opts ...option.ActionOption) error {
|
|||||||
startTime := int(time.Now().UnixMilli())
|
startTime := int(time.Now().UnixMilli())
|
||||||
hd.points = append(hd.points, ExportPoint{Start: startTime, End: startTime + 100, Ext: actionOptions.Identifier, RunTime: 100})
|
hd.points = append(hd.points, ExportPoint{Start: startTime, End: startTime + 100, Ext: actionOptions.Identifier, RunTime: 100})
|
||||||
}
|
}
|
||||||
return hd.uiDriver.InjectGesture(
|
err := hd.uiDriver.InjectGesture(
|
||||||
ghdc.NewGesture().Start(ghdc.Point{X: int(x), Y: int(y)}).Pause(100))
|
ghdc.NewGesture().Start(ghdc.Point{X: int(x), Y: int(y)}).Pause(100))
|
||||||
|
|
||||||
|
// mark UI operation
|
||||||
|
if actionOptions.MarkOperationEnabled {
|
||||||
|
if markErr := MarkUIOperation(hd, ACTION_TapAbsXY, []float64{x, y}); markErr != nil {
|
||||||
|
log.Warn().Err(markErr).Msg("Failed to mark tap operation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hd *HDCDriver) DoubleTap(x, y float64, opts ...option.ActionOption) error {
|
func (hd *HDCDriver) DoubleTap(x, y float64, opts ...option.ActionOption) error {
|
||||||
@@ -197,9 +206,18 @@ func (hd *HDCDriver) Swipe(fromX, fromY, toX, toY float64, opts ...option.Action
|
|||||||
startTime := int(time.Now().UnixMilli())
|
startTime := int(time.Now().UnixMilli())
|
||||||
hd.points = append(hd.points, ExportPoint{Start: startTime, End: startTime + 100, Ext: actionOptions.Identifier, RunTime: 100})
|
hd.points = append(hd.points, ExportPoint{Start: startTime, End: startTime + 100, Ext: actionOptions.Identifier, RunTime: 100})
|
||||||
}
|
}
|
||||||
return hd.uiDriver.InjectGesture(
|
err = hd.uiDriver.InjectGesture(
|
||||||
ghdc.NewGesture().Start(ghdc.Point{X: int(fromX), Y: int(fromY)}).
|
ghdc.NewGesture().Start(ghdc.Point{X: int(fromX), Y: int(fromY)}).
|
||||||
MoveTo(ghdc.Point{X: int(toX), Y: int(toY)}, duration))
|
MoveTo(ghdc.Point{X: int(toX), Y: int(toY)}, duration))
|
||||||
|
|
||||||
|
// mark UI operation
|
||||||
|
if actionOptions.MarkOperationEnabled {
|
||||||
|
if markErr := MarkUIOperation(hd, ACTION_Swipe, []float64{fromX, fromY, toX, toY}); markErr != nil {
|
||||||
|
log.Warn().Err(markErr).Msg("Failed to mark swipe operation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hd *HDCDriver) SetIme(ime string) error {
|
func (hd *HDCDriver) SetIme(ime string) error {
|
||||||
|
|||||||
@@ -603,6 +603,14 @@ func (wd *WDADriver) TapAbsXY(x, y float64, opts ...option.ActionOption) error {
|
|||||||
|
|
||||||
urlStr := fmt.Sprintf("/session/%s/wda/tap/0", wd.Session.ID)
|
urlStr := fmt.Sprintf("/session/%s/wda/tap/0", wd.Session.ID)
|
||||||
_, err := wd.Session.POST(data, urlStr)
|
_, err := wd.Session.POST(data, urlStr)
|
||||||
|
|
||||||
|
// mark UI operation
|
||||||
|
if actionOptions.MarkOperationEnabled {
|
||||||
|
if markErr := MarkUIOperation(wd, ACTION_TapAbsXY, []float64{x, y}); markErr != nil {
|
||||||
|
log.Warn().Err(markErr).Msg("Failed to mark tap operation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,6 +674,14 @@ func (wd *WDADriver) Drag(fromX, fromY, toX, toY float64, opts ...option.ActionO
|
|||||||
urlStr := fmt.Sprintf("/session/%s/wda/dragfromtoforduration", wd.Session.ID)
|
urlStr := fmt.Sprintf("/session/%s/wda/dragfromtoforduration", wd.Session.ID)
|
||||||
_, err = wd.Session.POST(data, urlStr)
|
_, err = wd.Session.POST(data, urlStr)
|
||||||
// _, err = wd.Session.POST(data, "/session", wd.Session.ID, "/wda/drag")
|
// _, err = wd.Session.POST(data, "/session", wd.Session.ID, "/wda/drag")
|
||||||
|
|
||||||
|
// mark UI operation
|
||||||
|
if actionOptions.MarkOperationEnabled {
|
||||||
|
if markErr := MarkUIOperation(wd, ACTION_Drag, []float64{fromX, fromY, toX, toY}); markErr != nil {
|
||||||
|
log.Warn().Err(markErr).Msg("Failed to mark drag operation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ func (o *ActionOptions) Options() []ActionOption {
|
|||||||
|
|
||||||
options = append(options, o.GetScreenShotOptions()...)
|
options = append(options, o.GetScreenShotOptions()...)
|
||||||
options = append(options, o.GetScreenRecordOptions()...)
|
options = append(options, o.GetScreenRecordOptions()...)
|
||||||
|
options = append(options, o.GetMarkOperationOptions()...)
|
||||||
|
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type ScreenOptions struct {
|
|||||||
ScreenShotOptions
|
ScreenShotOptions
|
||||||
ScreenRecordOptions
|
ScreenRecordOptions
|
||||||
ScreenFilterOptions
|
ScreenFilterOptions
|
||||||
|
MarkOperationOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScreenShotOptions struct {
|
type ScreenShotOptions struct {
|
||||||
@@ -273,3 +274,29 @@ func WithIndex(index int) ActionOption {
|
|||||||
o.Index = index
|
o.Index = index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkOperationOptions contains options for marking UI operations
|
||||||
|
type MarkOperationOptions struct {
|
||||||
|
// mark UI operation, enable/disable UI operation marking
|
||||||
|
MarkOperationEnabled bool `json:"mark_operation_enabled,omitempty" yaml:"mark_operation_enabled,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *MarkOperationOptions) GetMarkOperationOptions() []ActionOption {
|
||||||
|
options := make([]ActionOption, 0)
|
||||||
|
if o == nil {
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.MarkOperationEnabled {
|
||||||
|
options = append(options, WithMarkOperationEnabled(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMarkOperationEnabled enables or disables UI operation marking
|
||||||
|
func WithMarkOperationEnabled(enabled bool) ActionOption {
|
||||||
|
return func(o *ActionOptions) {
|
||||||
|
o.MarkOperationEnabled = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user