android-midscene-automation 0.1.26 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,7 +34,11 @@ export type InsertAction =
34
34
  | 'assertText'
35
35
  | 'waitDisappear'
36
36
  | 'waitActivity'
37
- | 'runScript';
37
+ | 'runScript'
38
+ | 'noop'
39
+ | 'visualChangeStart'
40
+ | 'visualChangeEnd'
41
+ | 'visualChange';
38
42
 
39
43
  export const PASTE_COMMAND = '__paste_flow_nodes__';
40
44
  export const FLOW_STEP_NODE_WIDTH = 340;
@@ -50,6 +54,7 @@ const FLOW_STANDARD_LINE_GAP = 24;
50
54
  const FLOW_BRANCH_TRUNK_GAP = 96;
51
55
  const FLOW_BRANCH_LABEL_GAP = 58;
52
56
  const FLOW_BRANCH_MIN_SPREAD = 260;
57
+ const FLOW_BRANCH_SUBTREE_GAP = 80;
53
58
 
54
59
  export type FlowActionGroup = {
55
60
  title: string;
@@ -137,9 +142,16 @@ type Link = {
137
142
  visual?: boolean;
138
143
  };
139
144
 
145
+ type BranchLayout = {
146
+ yesWidth: number;
147
+ noWidth: number;
148
+ centerGap: number;
149
+ totalWidth: number;
150
+ };
151
+
140
152
  function defaultKind(step: AppiumRecordedStep): FlowKind {
141
153
  if (step.flow?.nodeKind) return step.flow.nodeKind;
142
- if (step.type === 'assertExists' || step.type === 'assertText') return 'assertion';
154
+ if (step.type === 'assertExists' || step.type === 'assertText' || step.type === 'visualChange') return 'assertion';
143
155
  return 'action';
144
156
  }
145
157
 
@@ -232,6 +244,21 @@ function estimateStepNodeHeight(label: { title: string; meta: string; note?: str
232
244
  return Math.max(FLOW_STEP_NODE_HEIGHT, 34 + textHeight);
233
245
  }
234
246
 
247
+ function withLaunchAppAction(groups: FlowActionGroup[]) {
248
+ if (groups.some((group) => group.actions.some((action) => action.type === 'launchApp'))) return groups;
249
+ const launchAction = { type: 'launchApp' as const, label: '启动 App' };
250
+ let added = false;
251
+ const nextGroups = groups.map((group) => {
252
+ if (group.title !== '设备操作') return group;
253
+ added = true;
254
+ return {
255
+ ...group,
256
+ actions: [...group.actions, launchAction],
257
+ };
258
+ });
259
+ return added ? nextGroups : [...nextGroups, { title: '设备操作', actions: [launchAction] }];
260
+ }
261
+
235
262
  export function buildFlowGraph(
236
263
  steps: AppiumRecordedStep[],
237
264
  options: BuildFlowGraphOptions,
@@ -242,9 +269,63 @@ export function buildFlowGraph(
242
269
  const layoutLinks: Link[] = [];
243
270
  const seenLinks = new Set<string>();
244
271
  const allMainItems = mainItems(items);
272
+ const branchLayouts = new Map<string, BranchLayout>();
273
+ const measuringBranches = new Set<string>();
274
+
275
+ function conditionBranchLayout(condition: AppiumRecordedStep): BranchLayout {
276
+ const cached = branchLayouts.get(condition.id);
277
+ if (cached) return cached;
278
+ if (measuringBranches.has(condition.id)) {
279
+ return {
280
+ yesWidth: FLOW_STEP_NODE_WIDTH,
281
+ noWidth: FLOW_STEP_NODE_WIDTH,
282
+ centerGap: FLOW_BRANCH_MIN_SPREAD * 2,
283
+ totalWidth: FLOW_BRANCH_MIN_SPREAD * 2 + FLOW_STEP_NODE_WIDTH,
284
+ };
285
+ }
286
+
287
+ measuringBranches.add(condition.id);
288
+ const branchWidth = (branch: FlowBranch) => Math.max(
289
+ FLOW_STEP_NODE_WIDTH,
290
+ ...directBranchItems(items, condition.id, branch).map(({ step }) => (
291
+ defaultKind(step) === 'condition'
292
+ ? conditionBranchLayout(step).totalWidth
293
+ : FLOW_STEP_NODE_WIDTH
294
+ )),
295
+ );
296
+ const yesWidth = branchWidth('yes');
297
+ const noWidth = branchWidth('no');
298
+ const centerGap = Math.max(
299
+ FLOW_BRANCH_MIN_SPREAD * 2,
300
+ yesWidth / 2 + noWidth / 2 + FLOW_BRANCH_SUBTREE_GAP,
301
+ );
302
+ const sideExtent = Math.max(
303
+ centerGap / 2 + yesWidth / 2,
304
+ centerGap / 2 + noWidth / 2,
305
+ );
306
+ const layout = {
307
+ yesWidth,
308
+ noWidth,
309
+ centerGap,
310
+ totalWidth: Math.max(FLOW_STEP_NODE_WIDTH, sideExtent * 2),
311
+ };
312
+ measuringBranches.delete(condition.id);
313
+ branchLayouts.set(condition.id, layout);
314
+ return layout;
315
+ }
316
+
317
+ items
318
+ .filter(({ step }) => defaultKind(step) === 'condition')
319
+ .forEach(({ step }) => conditionBranchLayout(step));
245
320
 
246
321
  const addNode = (node: FlowGraphNode) => {
247
- nodes.push(node);
322
+ nodes.push({
323
+ ...node,
324
+ dimensions: {
325
+ width: Number(node.width) || FLOW_STEP_NODE_WIDTH,
326
+ height: Number(node.height) || FLOW_STEP_NODE_HEIGHT,
327
+ },
328
+ } as FlowGraphNode);
248
329
  };
249
330
  const addVisibleLink = (source: string, target: string, branch?: FlowBranch) => {
250
331
  addUniqueLink(links, seenLinks, {
@@ -266,6 +347,11 @@ export function buildFlowGraph(
266
347
  ) => {
267
348
  const id = insertNodeId(afterIndex, branch, condition?.step.id);
268
349
  const isStartInsert = afterIndex < 0 && !branch;
350
+ const previousStep = afterIndex >= 0 ? steps[afterIndex] : undefined;
351
+ const canLaunchAfterClearAppData = !isStartInsert
352
+ && !branch
353
+ && previousStep?.type === 'clearAppData'
354
+ && !steps.some((step) => step.type === 'launchApp');
269
355
  addNode({
270
356
  id,
271
357
  type: 'flow-node',
@@ -279,7 +365,11 @@ export function buildFlowGraph(
279
365
  connectable: false,
280
366
  data: {
281
367
  kind: 'insert',
282
- actionGroups: isStartInsert ? options.startActionGroups : options.mainActionGroups,
368
+ actionGroups: isStartInsert
369
+ ? options.startActionGroups
370
+ : canLaunchAfterClearAppData
371
+ ? withLaunchAppAction(options.mainActionGroups)
372
+ : options.mainActionGroups,
283
373
  afterIndex,
284
374
  branch,
285
375
  conditionIndex: condition?.index,
@@ -328,7 +418,9 @@ export function buildFlowGraph(
328
418
  : options.disabled,
329
419
  removeDisabled: options.removeDisabled,
330
420
  launching: options.launchingStepId === item.step.id,
331
- canCopy: item.step.type !== 'launchApp' && item.step.type !== 'clearAppData',
421
+ canCopy: item.step.type !== 'launchApp'
422
+ && item.step.type !== 'clearAppData'
423
+ && item.step.visualChange?.role !== 'end',
332
424
  canEditInput: item.step.type === 'input' || item.step.type === 'inputIfExists',
333
425
  canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData',
334
426
  },
@@ -527,7 +619,10 @@ export function buildFlowGraph(
527
619
  const yesNode = nodeById.get(branchLinks.find((link) => link.branch === 'yes')?.target || '');
528
620
  const noNode = nodeById.get(branchLinks.find((link) => link.branch === 'no')?.target || '');
529
621
  const splitX = centerX(splitNode);
530
- const spread = FLOW_BRANCH_MIN_SPREAD;
622
+ const conditionId = splitNode.id.startsWith('split:')
623
+ ? splitNode.id.slice('split:'.length)
624
+ : '';
625
+ const spread = (branchLayouts.get(conditionId)?.centerGap || FLOW_BRANCH_MIN_SPREAD * 2) / 2;
531
626
  if (yesNode) setCenterX(yesNode, splitX - spread);
532
627
  if (noNode) setCenterX(noNode, splitX + spread);
533
628
  });
@@ -1,9 +1,10 @@
1
1
  import type { FlowKind } from './flow-graph';
2
2
  import type { AppiumRecordedStep } from './types';
3
+ import { visualChangeMeta } from './visual-change';
3
4
 
4
5
  export function defaultFlowKind(step: AppiumRecordedStep): FlowKind {
5
6
  if (step.flow?.nodeKind) return step.flow.nodeKind;
6
- if (step.type === 'assertExists' || step.type === 'assertText') return 'assertion';
7
+ if (step.type === 'assertExists' || step.type === 'assertText' || step.type === 'visualChange') return 'assertion';
7
8
  return 'action';
8
9
  }
9
10
 
@@ -35,6 +36,8 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
35
36
  longPress: '长按',
36
37
  pinch: '双指缩放',
37
38
  runScript: '连接脚本',
39
+ noop: '空节点',
40
+ visualChange: '检测画面变化',
38
41
  };
39
42
  return labelMap[step.type] || step.type;
40
43
  }
@@ -53,7 +56,9 @@ export function flowStepMeta(step: AppiumRecordedStep) {
53
56
  if (step.type === 'launchApp') return step.value || '';
54
57
  if (step.type === 'clearAppData') return `${step.value || ''}(清除数据与缓存)`;
55
58
  if (step.type === 'runScript') return step.value ? `脚本 ${step.value}` : '';
59
+ if (step.type === 'noop') return '';
56
60
  if (step.type === 'screenshot') return '保存当前截图';
61
+ if (step.type === 'visualChange') return visualChangeMeta(step);
57
62
  if (step.type === 'swipe') {
58
63
  const swipe = step.swipe;
59
64
  return swipe ? `[${swipe.startX},${swipe.startY}] -> [${swipe.endX},${swipe.endY}]` : '';
@@ -0,0 +1,67 @@
1
+ import type { AppiumRecordedStep } from './types';
2
+
3
+ type BranchName = 'yes' | 'no';
4
+
5
+ function isCondition(step: AppiumRecordedStep) {
6
+ return step.flow?.nodeKind === 'condition';
7
+ }
8
+
9
+ function isEmptyNestedCondition(step: AppiumRecordedStep) {
10
+ return isCondition(step)
11
+ && Boolean(step.flow?.parentConditionId)
12
+ && Boolean(step.flow?.parentBranch)
13
+ && !step.flow?.yesTargetId
14
+ && !step.flow?.noTargetId
15
+ && !step.flow?.successTargetId;
16
+ }
17
+
18
+ function sameParentBranch(step: AppiumRecordedStep, parentConditionId: string, parentBranch: BranchName) {
19
+ return step.flow?.parentConditionId === parentConditionId
20
+ && step.flow?.parentBranch === parentBranch;
21
+ }
22
+
23
+ export function normalizeLegacyNestedConditionBranches<T extends AppiumRecordedStep>(steps: T[]) {
24
+ const nextSteps = [...steps];
25
+ let changed = false;
26
+
27
+ for (let index = 0; index < nextSteps.length; index += 1) {
28
+ const condition = nextSteps[index];
29
+ if (!condition || !isEmptyNestedCondition(condition)) continue;
30
+
31
+ const parentConditionId = condition.flow?.parentConditionId || '';
32
+ const parentBranch = condition.flow?.parentBranch;
33
+ if (!parentConditionId || !parentBranch) continue;
34
+
35
+ const childIndexes: number[] = [];
36
+ for (let childIndex = index + 1; childIndex < nextSteps.length; childIndex += 1) {
37
+ if (!sameParentBranch(nextSteps[childIndex], parentConditionId, parentBranch)) break;
38
+ childIndexes.push(childIndex);
39
+ }
40
+ if (!childIndexes.length) continue;
41
+
42
+ const firstChild = nextSteps[childIndexes[0]];
43
+ nextSteps[index] = {
44
+ ...condition,
45
+ flow: {
46
+ ...(condition.flow || {}),
47
+ nodeKind: 'condition',
48
+ yesTargetId: firstChild.id,
49
+ },
50
+ };
51
+
52
+ childIndexes.forEach((childIndex) => {
53
+ const child = nextSteps[childIndex];
54
+ nextSteps[childIndex] = {
55
+ ...child,
56
+ flow: {
57
+ ...(child.flow || {}),
58
+ parentConditionId: condition.id,
59
+ parentBranch: 'yes',
60
+ },
61
+ };
62
+ });
63
+ changed = true;
64
+ }
65
+
66
+ return changed ? nextSteps : steps;
67
+ }
@@ -0,0 +1,67 @@
1
+ import type { AppiumRecordedStep } from './types';
2
+
3
+ type FlowTargetKey = 'yesTargetId' | 'noTargetId' | 'successTargetId' | 'failureTargetId';
4
+
5
+ const FLOW_TARGET_KEYS: FlowTargetKey[] = ['yesTargetId', 'noTargetId', 'successTargetId', 'failureTargetId'];
6
+
7
+ function sameBranch(step: AppiumRecordedStep, removed: AppiumRecordedStep) {
8
+ return Boolean(
9
+ removed.flow?.parentConditionId
10
+ && removed.flow?.parentBranch
11
+ && step.flow?.parentConditionId === removed.flow.parentConditionId
12
+ && step.flow?.parentBranch === removed.flow.parentBranch,
13
+ );
14
+ }
15
+
16
+ function nextSequentialStep(steps: AppiumRecordedStep[], removed: AppiumRecordedStep, index: number) {
17
+ if (removed.flow?.parentConditionId && removed.flow.parentBranch) {
18
+ return steps.slice(index + 1).find((step) => sameBranch(step, removed));
19
+ }
20
+ return steps.slice(index + 1).find((step) => !step.flow?.parentConditionId);
21
+ }
22
+
23
+ function cleanFlow(flow: NonNullable<AppiumRecordedStep['flow']>) {
24
+ return Object.keys(flow).length ? flow : undefined;
25
+ }
26
+
27
+ export function removeFlowStep(steps: AppiumRecordedStep[], index: number) {
28
+ const removed = steps[index];
29
+ if (!removed) return steps;
30
+
31
+ const sequentialStep = nextSequentialStep(steps, removed, index);
32
+ const replacementId = removed.flow?.successTargetId || sequentialStep?.id || '';
33
+ const removedParentConditionId = removed.flow?.parentConditionId;
34
+ const removedParentBranch = removed.flow?.parentBranch;
35
+
36
+ return steps
37
+ .filter((_step, stepIndex) => stepIndex !== index)
38
+ .map((step) => {
39
+ if (!step.flow) return step;
40
+
41
+ const flow = { ...step.flow };
42
+ let changed = false;
43
+
44
+ FLOW_TARGET_KEYS.forEach((key) => {
45
+ if (flow[key] !== removed.id) return;
46
+ if (replacementId) {
47
+ flow[key] = replacementId;
48
+ } else {
49
+ delete flow[key];
50
+ }
51
+ changed = true;
52
+ });
53
+
54
+ if (flow.parentConditionId === removed.id) {
55
+ if (removedParentConditionId && removedParentBranch) {
56
+ flow.parentConditionId = removedParentConditionId;
57
+ flow.parentBranch = removedParentBranch;
58
+ } else {
59
+ delete flow.parentConditionId;
60
+ delete flow.parentBranch;
61
+ }
62
+ changed = true;
63
+ }
64
+
65
+ return changed ? { ...step, flow: cleanFlow(flow) } : step;
66
+ });
67
+ }
@@ -24,6 +24,27 @@ export type AppiumPageSnapshot = {
24
24
  selectedNodePath?: string;
25
25
  };
26
26
 
27
+ export type AppiumVisualChangeRegion = {
28
+ x: number;
29
+ y: number;
30
+ width: number;
31
+ height: number;
32
+ };
33
+
34
+ export type AppiumVisualChangeConfig = {
35
+ mode: 'selectedElement' | 'region';
36
+ region: AppiumVisualChangeRegion;
37
+ role?: 'start' | 'end';
38
+ pairId?: string;
39
+ pairLabel?: string;
40
+ startStepId?: string;
41
+ endStepId?: string;
42
+ durationMs: number;
43
+ intervalMs: number;
44
+ changeRatioThreshold: number;
45
+ pixelmatchThreshold: number;
46
+ };
47
+
27
48
  export type AppiumNode = {
28
49
  id: string;
29
50
  label: string;
@@ -65,7 +86,9 @@ export type AppiumRecordedStep = {
65
86
  | 'assertText'
66
87
  | 'longPress'
67
88
  | 'pinch'
68
- | 'runScript';
89
+ | 'runScript'
90
+ | 'noop'
91
+ | 'visualChange';
69
92
  label: string;
70
93
  note?: string;
71
94
  selector?: AppiumSelector;
@@ -102,6 +125,7 @@ export type AppiumRecordedStep = {
102
125
  centerY: number;
103
126
  percent: number;
104
127
  };
128
+ visualChange?: AppiumVisualChangeConfig;
105
129
  snapshot?: {
106
130
  text: string;
107
131
  resourceId: string;
@@ -0,0 +1,147 @@
1
+ import type { AppiumRecordedStep } from './types';
2
+
3
+ export type VisualChangeBranchTarget = {
4
+ stepId: string;
5
+ branch: 'yes' | 'no';
6
+ };
7
+
8
+ const FLOW_TARGET_KEYS = ['successTargetId', 'failureTargetId'] as const;
9
+
10
+ export function visualChangePairNumber(label?: string) {
11
+ const match = /^检测画面变化(\d+)$/.exec(label || '');
12
+ return match ? Number(match[1]) : 0;
13
+ }
14
+
15
+ export function nextVisualChangePairLabel(steps: AppiumRecordedStep[]) {
16
+ const maxNumber = steps.reduce((max, step) => Math.max(
17
+ max,
18
+ visualChangePairNumber(step.visualChange?.pairLabel),
19
+ ), 0);
20
+ return `检测画面变化${maxNumber + 1}`;
21
+ }
22
+
23
+ function rawBranchPathForStep(steps: AppiumRecordedStep[], step?: AppiumRecordedStep) {
24
+ const path: string[] = [];
25
+ let current = step;
26
+ let guard = 0;
27
+ while (current?.flow?.parentConditionId && current.flow.parentBranch && guard < steps.length) {
28
+ path.unshift(`${current.flow.parentConditionId}:${current.flow.parentBranch}`);
29
+ current = steps.find((item) => item.id === current?.flow?.parentConditionId);
30
+ guard += 1;
31
+ }
32
+ return path;
33
+ }
34
+
35
+ function uniqueBranchPaths(paths: string[][]) {
36
+ const seen = new Set<string>();
37
+ return paths.filter((path) => {
38
+ const key = path.join('|');
39
+ if (!path.length || seen.has(key)) return false;
40
+ seen.add(key);
41
+ return true;
42
+ });
43
+ }
44
+
45
+ function branchTargetPath(
46
+ steps: AppiumRecordedStep[],
47
+ condition: AppiumRecordedStep,
48
+ branch: 'yes' | 'no',
49
+ seen: Set<string>,
50
+ ) {
51
+ return [...branchPathForStep(steps, condition, new Set(seen)), `${condition.id}:${branch}`];
52
+ }
53
+
54
+ export function branchPathForStep(
55
+ steps: AppiumRecordedStep[],
56
+ step?: AppiumRecordedStep,
57
+ seen = new Set<string>(),
58
+ ) {
59
+ const rawPath = rawBranchPathForStep(steps, step);
60
+ if (rawPath.length || !step || seen.has(step.id)) return rawPath;
61
+ seen.add(step.id);
62
+
63
+ const incomingPaths = uniqueBranchPaths(steps.flatMap((source) => {
64
+ if (!source.flow || source.id === step.id) return [];
65
+ const paths: string[][] = [];
66
+ if (source.flow.nodeKind === 'condition') {
67
+ if (source.flow.yesTargetId === step.id) paths.push(branchTargetPath(steps, source, 'yes', seen));
68
+ if (source.flow.noTargetId === step.id) paths.push(branchTargetPath(steps, source, 'no', seen));
69
+ }
70
+ FLOW_TARGET_KEYS.forEach((key) => {
71
+ if (source.flow?.[key] === step.id) paths.push(branchPathForStep(steps, source, new Set(seen)));
72
+ });
73
+ return paths;
74
+ }));
75
+
76
+ return incomingPaths.length === 1 ? incomingPaths[0] : [];
77
+ }
78
+
79
+ // Branch paths are stored as an ancestry chain like
80
+ // ["outer:yes", "inner:no"]. A root node before a condition has an empty
81
+ // path, so it can be closed by an end marker in either downstream branch.
82
+ export function branchPathForInsert(
83
+ steps: AppiumRecordedStep[],
84
+ index?: number,
85
+ branchTarget?: VisualChangeBranchTarget,
86
+ ) {
87
+ if (branchTarget) {
88
+ const condition = steps.find((step) => step.id === branchTarget.stepId);
89
+ return [...branchPathForStep(steps, condition), `${branchTarget.stepId}:${branchTarget.branch}`];
90
+ }
91
+ return typeof index === 'number' && index >= 0
92
+ ? branchPathForStep(steps, steps[index])
93
+ : [];
94
+ }
95
+
96
+ function sameBranchPath(left: string[], right: string[]) {
97
+ return left.length === right.length && left.every((value, index) => value === right[index]);
98
+ }
99
+
100
+ function branchPathContains(parent: string[], child: string[]) {
101
+ return parent.length <= child.length && parent.every((value, index) => value === child[index]);
102
+ }
103
+
104
+ function visualChangeRole(step: AppiumRecordedStep) {
105
+ return step.visualChange?.role;
106
+ }
107
+
108
+ function isEndForStart(endStep: AppiumRecordedStep, startStep: AppiumRecordedStep) {
109
+ const start = startStep.visualChange;
110
+ const end = endStep.visualChange;
111
+ if (!start || end?.role !== 'end') return false;
112
+ if (start.pairId && end.pairId === start.pairId) return true;
113
+ if (end.startStepId && end.startStepId === startStep.id) return true;
114
+ return Boolean(
115
+ start.pairLabel
116
+ && end.pairLabel === start.pairLabel
117
+ && (!start.pairId || !end.pairId || !end.startStepId),
118
+ );
119
+ }
120
+
121
+ export function findOpenVisualChangeStart(
122
+ steps: AppiumRecordedStep[],
123
+ index?: number,
124
+ branchTarget?: VisualChangeBranchTarget,
125
+ ) {
126
+ const insertPath = branchPathForInsert(steps, index, branchTarget);
127
+ const insertIndex = typeof index === 'number' ? index : steps.length - 1;
128
+ // A start marker may be closed once per concrete branch path. This allows a
129
+ // root start before a condition to have independent yes/no end markers, while
130
+ // preventing a yes-branch start from pairing with a no-branch end.
131
+ const hasEndInInsertBranch = (startStep: AppiumRecordedStep) => steps.some((step) => (
132
+ isEndForStart(step, startStep)
133
+ && sameBranchPath(branchPathForStep(steps, step), insertPath)
134
+ ));
135
+
136
+ return steps
137
+ .map((step, stepIndex) => ({ step, stepIndex, path: branchPathForStep(steps, step) }))
138
+ .filter(({ step, stepIndex, path }) => (
139
+ visualChangeRole(step) === 'start'
140
+ && stepIndex <= insertIndex
141
+ && branchPathContains(path, insertPath)
142
+ && !hasEndInInsertBranch(step)
143
+ ))
144
+ .sort((left, right) => (
145
+ right.path.length - left.path.length || right.stepIndex - left.stepIndex
146
+ ))[0]?.step;
147
+ }
@@ -0,0 +1,75 @@
1
+ import type { AppiumBounds, AppiumRecordedStep } from './types';
2
+
3
+ export const VISUAL_CHANGE_DEFAULTS = {
4
+ durationMs: 5000,
5
+ intervalMs: 1000,
6
+ changeRatioThreshold: 2,
7
+ pixelmatchThreshold: 0.1,
8
+ } as const;
9
+
10
+ export function createStepId() {
11
+ return `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
12
+ }
13
+
14
+ export function boundsToVisualRegion(bounds: AppiumBounds) {
15
+ return {
16
+ x: bounds.left,
17
+ y: bounds.top,
18
+ width: Math.max(0, bounds.right - bounds.left),
19
+ height: Math.max(0, bounds.bottom - bounds.top),
20
+ };
21
+ }
22
+
23
+ export function normalizeVisualRegion(
24
+ region: NonNullable<AppiumRecordedStep['visualChange']>['region'],
25
+ ) {
26
+ return {
27
+ x: Math.max(0, Math.round(Number(region?.x) || 0)),
28
+ y: Math.max(0, Math.round(Number(region?.y) || 0)),
29
+ width: Math.max(1, Math.round(Number(region?.width) || 1)),
30
+ height: Math.max(1, Math.round(Number(region?.height) || 1)),
31
+ };
32
+ }
33
+
34
+ export function normalizeVisualChangeConfig(
35
+ config?: AppiumRecordedStep['visualChange'],
36
+ ): NonNullable<AppiumRecordedStep['visualChange']> {
37
+ return {
38
+ mode: config?.mode === 'selectedElement' ? 'selectedElement' : 'region',
39
+ region: normalizeVisualRegion(config?.region || { x: 0, y: 0, width: 1, height: 1 }),
40
+ role: config?.role === 'start' ? 'start' : config?.role === 'end' ? 'end' : undefined,
41
+ pairId: config?.pairId || '',
42
+ pairLabel: config?.pairLabel || '',
43
+ startStepId: config?.startStepId || '',
44
+ endStepId: config?.endStepId || '',
45
+ durationMs: Math.max(1000, Math.round(Number(config?.durationMs) || VISUAL_CHANGE_DEFAULTS.durationMs)),
46
+ intervalMs: Math.max(200, Math.round(Number(config?.intervalMs) || VISUAL_CHANGE_DEFAULTS.intervalMs)),
47
+ changeRatioThreshold: Math.max(0.01, Number(config?.changeRatioThreshold) || VISUAL_CHANGE_DEFAULTS.changeRatioThreshold),
48
+ pixelmatchThreshold: Math.min(
49
+ 1,
50
+ Math.max(0, Number(config?.pixelmatchThreshold) || VISUAL_CHANGE_DEFAULTS.pixelmatchThreshold),
51
+ ),
52
+ };
53
+ }
54
+
55
+ export function visualRegionToBounds(
56
+ id: string,
57
+ region: NonNullable<AppiumRecordedStep['visualChange']>['region'],
58
+ ) {
59
+ const normalized = normalizeVisualRegion(region);
60
+ return {
61
+ id,
62
+ left: normalized.x,
63
+ top: normalized.y,
64
+ right: normalized.x + normalized.width,
65
+ bottom: normalized.y + normalized.height,
66
+ };
67
+ }
68
+
69
+ export function visualChangeMeta(step: AppiumRecordedStep) {
70
+ const config = normalizeVisualChangeConfig(step.visualChange);
71
+ if (config.role === 'start') return `${config.pairLabel || '检测画面变化'} · 开始 · 阈值 ${config.changeRatioThreshold}%`;
72
+ if (config.role === 'end') return `${config.pairLabel || '检测画面变化'} · 结束`;
73
+ const range = config.startStepId && config.endStepId ? '起止节点截图对比' : '请选择起止节点';
74
+ return `${range} · 阈值 ${config.changeRatioThreshold}%`;
75
+ }