fix(ui): localize native Android action displays
This commit is contained in:
1 parent
4b12ae7728
commit
6a69a595ef
5 files changed
+192
-3
No files matched your search
@@ -621,3 +621,40 @@ describe('extractActionExtraParams', () => {
|
||||
expect(sequence.map(p => p.key)).toContain('Interval Ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('native action Chinese presentation', () => {
|
||||
it('renders a pinch trace in Chinese without modifying the recorded gesture', () => {
|
||||
const action = {
|
||||
name: 'pinch', args: {
|
||||
center: [360, 1040], start_span: 100, end_span: 400,
|
||||
duration_ms: 1000, coordinate_space: 'pixel', target_description: 'timeline'
|
||||
}
|
||||
};
|
||||
const original = JSON.stringify(action);
|
||||
expect(getActionTitle(action)).toBe('双指缩放/旋转');
|
||||
expect(extractActionExtraParams(action)).toContain({ key: '坐标空间', value: '像素坐标' });
|
||||
expect(extractActionExtraParams(action)).toContain({ key: '起始指间距', value: '100' });
|
||||
expect(JSON.stringify(action)).toBe(original);
|
||||
});
|
||||
|
||||
it('translates sequence members while preserving literal target text and IDs', () => {
|
||||
const params = extractActionExtraParams({ action: 'action_sequence', steps: [
|
||||
{ action: 'control_action', args: {
|
||||
selector: { text: 'visible', resource_id: 'demo:id/set_text' },
|
||||
action: 'replace_selection', text: 'set_text'
|
||||
} },
|
||||
{ action: 'wait_until', state: 'stable', stable_ms: 500 },
|
||||
{ action: 'click', args: { target: [100, 200] } },
|
||||
{ action: 'wait', time_in_ms: 100 }
|
||||
] });
|
||||
const steps = JSON.parse(params.find(param => param.key === '动作步骤')!.value);
|
||||
expect(steps[0]['操作']).toBe('操作原生控件');
|
||||
expect(steps[0]['参数']['操作']).toBe('替换选区文本');
|
||||
expect(steps[0]['参数']['控件选择器']['文本']).toBe('visible');
|
||||
expect(steps[0]['参数']['控件选择器']['资源 ID']).toBe('demo:id/set_text');
|
||||
expect(steps[0]['参数']['文本']).toBe('set_text');
|
||||
expect(steps[1]['等待条件']).toBe('持续稳定');
|
||||
expect(steps[2]['操作']).toBe('点击元素');
|
||||
expect(steps[3]['操作']).toBe('等待');
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { ActionParam, StepReplayFrame } from '../core/models/stream.model';
|
||||
import { extractNumbersFromCoordinateValue, isPureDirectionString, parseSequenceCoordinates, unwrapTraceAction } from './image-overlay.util';
|
||||
import { cleanErrorMessage, joinTargetDescriptions } from './tool-formatter.util';
|
||||
import { getPluginToolView } from './plugin-tool-view.util';
|
||||
import { formatNativeActionParam, getNativeActionArgs, getNativeActionTitle } from './native-action-labels.util';
|
||||
|
||||
/**
|
||||
* Safely parse JSON string if it looks like an object/array, otherwise return original value
|
||||
@@ -170,6 +171,8 @@ export function getActionTitle(action: any): string {
|
||||
const plugin = getPluginToolView(act);
|
||||
if (plugin) return plugin.title;
|
||||
const name = (act.name || act.action || '').toLowerCase();
|
||||
const nativeTitle = getNativeActionTitle(name, act.args || act);
|
||||
if (nativeTitle) return nativeTitle;
|
||||
switch (name) {
|
||||
case 'tap':
|
||||
case 'click':
|
||||
@@ -539,6 +542,8 @@ export function extractActionExtraParams(action: any, cache?: WeakMap<any, Actio
|
||||
}
|
||||
|
||||
const mergedObj = { ...act, ...parsedArgs };
|
||||
const native = getNativeActionTitle(act.name || act.action || '', parsedArgs) !== null;
|
||||
if (native) Object.assign(mergedObj, getNativeActionArgs(parsedArgs));
|
||||
|
||||
// Explicitly check duration if available and not already formatted
|
||||
const dur = mergedObj.duration || mergedObj.duration_ms;
|
||||
@@ -548,8 +553,12 @@ export function extractActionExtraParams(action: any, cache?: WeakMap<any, Actio
|
||||
|
||||
for (const [k, v] of Object.entries(mergedObj)) {
|
||||
const lowerK = k.toLowerCase();
|
||||
if (standardKeys.has(lowerK)) continue;
|
||||
if (standardKeys.has(lowerK) && !(native && lowerK === 'target')) continue;
|
||||
if (v === null || v === undefined || v === '') continue;
|
||||
if (native) {
|
||||
if (lowerK !== 'duration_ms') result.push(formatNativeActionParam(k, v));
|
||||
continue;
|
||||
}
|
||||
|
||||
let valStr = String(v);
|
||||
if (valStr.includes('object at 0x') || valStr.startsWith('<artemis.') || valStr.includes('<controller')) continue;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ActionParam } from '../core/models/stream.model';
|
||||
|
||||
// Display-only translations. Protocol names and original trace payloads stay intact.
|
||||
const ACTION_LABELS = new Map(Object.entries({
|
||||
pinch: '双指缩放/旋转',
|
||||
touch_gesture: '连续多指手势',
|
||||
read_control: '读取原生控件',
|
||||
control_action: '操作原生控件',
|
||||
wait_until: '等待控件条件',
|
||||
scroll_to: '滚动查找控件',
|
||||
action_sequence: '动作序列'
|
||||
}));
|
||||
|
||||
const CONTROL_LABELS = new Map(Object.entries({
|
||||
click: '原生点击', long_click: '原生长按', focus: '聚焦控件',
|
||||
show_on_screen: '显示控件', set_progress: '设置控件值',
|
||||
scroll_forward: '向前滚动', scroll_backward: '向后滚动',
|
||||
scroll_up: '向上滚动', scroll_down: '向下滚动',
|
||||
scroll_left: '向左滚动', scroll_right: '向右滚动',
|
||||
set_selection: '设置文本选区', replace_selection: '替换选区文本',
|
||||
set_text: '设置文本', ime_enter: '提交输入法操作'
|
||||
}));
|
||||
|
||||
const STEP_LABELS = new Map(Object.entries({
|
||||
click: '点击元素', long_press: '长按元素', drag: '拖动屏幕', wait: '等待'
|
||||
}));
|
||||
|
||||
const PARAM_LABELS = new Map(Object.entries({
|
||||
center: '中心点', start_span: '起始指间距', end_span: '结束指间距',
|
||||
angle_degrees: '起始角度', rotation_degrees: '旋转角度',
|
||||
duration_ms: '持续时间(毫秒)', coordinate_space: '坐标空间',
|
||||
frames: '触摸轨迹', at_ms: '时间点(毫秒)', points: '触点',
|
||||
selector: '控件选择器', container: '滚动容器', target: '目标控件',
|
||||
resource_id: '资源 ID', text: '文本', content_desc: '控件描述',
|
||||
class_name: '控件类名', package_name: '应用包名', bounds: '控件边界',
|
||||
expected_token: '预期控件标识', value: '目标值',
|
||||
selection_start: '选区起点', selection_end: '选区终点',
|
||||
state: '等待条件', expected_text: '预期文本', expected_value: '预期值',
|
||||
tolerance: '允许误差', timeout_ms: '超时(毫秒)',
|
||||
poll_interval_ms: '检查间隔(毫秒)', stable_ms: '稳定时长(毫秒)',
|
||||
direction: '滚动方向', max_scrolls: '最多滚动次数', settle_ms: '停稳时长(毫秒)',
|
||||
require_visible: '可见条件', unless_visible: '跳过条件',
|
||||
steps: '动作步骤', action: '操作', args: '参数',
|
||||
time_in_ms: '等待时长(毫秒)', target_description: '目标说明',
|
||||
execution: '执行状态', verification: '验证状态'
|
||||
}));
|
||||
|
||||
const ENUM_LABELS: Record<string, Map<string, string>> = {
|
||||
coordinate_space: new Map(Object.entries({ normalized: '归一化坐标', pixel: '像素坐标' })),
|
||||
state: new Map(Object.entries({
|
||||
visible: '可见', absent: '消失', enabled: '可用', disabled: '禁用',
|
||||
checked: '已勾选', unchecked: '未勾选', selected: '已选中',
|
||||
unselected: '未选中', text: '文本匹配', value: '数值匹配', stable: '持续稳定'
|
||||
})),
|
||||
direction: new Map(Object.entries({
|
||||
forward: '向前', backward: '向后', up: '向上', down: '向下', left: '向左', right: '向右'
|
||||
})),
|
||||
execution: new Map(Object.entries({
|
||||
not_started: '未开始', observed: '已观测', completed: '已完成',
|
||||
partial: '部分完成', cancelled: '已取消', unknown: '结果未知'
|
||||
})),
|
||||
verification: new Map(Object.entries({ pending_observation: '等待观测' }))
|
||||
};
|
||||
|
||||
export function getNativeActionTitle(name: string, args: any = {}): string | null {
|
||||
const clean = String(name).toLowerCase().replace(/^_?(?:self\.)?exec_/, '')
|
||||
.replace(/^action_sequence\./, '');
|
||||
if (clean === 'control_action') {
|
||||
const request = getNativeActionArgs(args);
|
||||
return CONTROL_LABELS.get(request.action) ?? ACTION_LABELS.get(clean)!;
|
||||
}
|
||||
return ACTION_LABELS.get(clean) ?? null;
|
||||
}
|
||||
|
||||
export function getNativeActionArgs(args: any): any {
|
||||
return args?.action === 'control_action' && args.args ? args.args : (args ?? {});
|
||||
}
|
||||
|
||||
function localizedValue(value: any, key: string, controlAction = false): any {
|
||||
if (Array.isArray(value)) return value.map(item => localizedValue(item, key, controlAction));
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(Object.entries(value).map(([field, item]) => [
|
||||
PARAM_LABELS.get(field) ?? field,
|
||||
localizedValue(item, field, field === 'action' ? controlAction : field === 'args' && value.action === 'control_action')
|
||||
]));
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
if (key === 'action') {
|
||||
return ACTION_LABELS.get(value) ?? (controlAction ? CONTROL_LABELS : STEP_LABELS).get(value) ?? value;
|
||||
}
|
||||
if (Object.hasOwn(ENUM_LABELS, key)) return ENUM_LABELS[key].get(value) ?? value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function formatNativeActionParam(key: string, value: any): ActionParam {
|
||||
const localized = localizedValue(value, key);
|
||||
return {
|
||||
key: PARAM_LABELS.get(key) ?? key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
|
||||
value: typeof localized === 'object' ? JSON.stringify(localized) : String(localized)
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
getCompressionPhaseLabel,
|
||||
isCompressionWaiting,
|
||||
getToolDisplayLabel,
|
||||
getToolTitle,
|
||||
getToolGenericDetails,
|
||||
getToolIcon,
|
||||
getToolTargetText,
|
||||
getUniqueGenericTools,
|
||||
@@ -373,3 +375,27 @@ describe('extractToolExtraParams', () => {
|
||||
expect(keys).toContain('Interval Ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('native tool Chinese presentation', () => {
|
||||
it('shares Chinese titles across live tool lines and expanded cards', () => {
|
||||
for (const name of ['pinch', 'touch_gesture', 'read_control', 'wait_until', 'scroll_to', 'action_sequence']) {
|
||||
const tool = { name: `self.exec_${name}`, payload: { args: {} } };
|
||||
expect(getToolTitle(tool)).toMatch(/[\u4e00-\u9fff]/);
|
||||
expect(getToolDisplayLabel(tool)).toBe(getToolTitle(tool));
|
||||
}
|
||||
});
|
||||
|
||||
it('understands nested native sequence calls and parsed payload arguments', () => {
|
||||
const tool = { name: 'action_sequence.control_action', payload: { args: {
|
||||
action: 'control_action', args: { action: 'ime_enter', selector: { content_desc: 'Send' } }
|
||||
} } };
|
||||
expect(getToolTitle(tool)).toBe('提交输入法操作');
|
||||
expect(getToolDisplayLabel(tool)).toBe('提交输入法操作');
|
||||
expect(extractToolExtraParams(tool)).toContain({ key: '控件选择器', value: '{"控件描述":"Send"}' });
|
||||
const wait = { name: 'wait_until', payload: JSON.stringify({ args: {
|
||||
state: 'text', expected_text: 'visible', timeout_ms: 3000
|
||||
} }) };
|
||||
expect(extractToolExtraParams(wait)).toContain({ key: '等待条件', value: '文本匹配' });
|
||||
expect(getToolGenericDetails(wait)).toContain('预期文本:visible');
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { ActionParam } from '../core/models/stream.model';
|
||||
import { extractNumbersFromCoordinateValue, isPureDirectionString, parseSequenceCoordinates } from './image-overlay.util';
|
||||
import { formatNativeActionParam, getNativeActionArgs, getNativeActionTitle } from './native-action-labels.util';
|
||||
|
||||
// Tool objects are replaced (not mutated) when a trace is updated, so a
|
||||
// WeakMap keyed on the tool is a safe memo for the parsed-args lookup that
|
||||
@@ -553,6 +554,8 @@ export function getToolDisplayLabel(tool: any, isFirstSaveNote: boolean = false)
|
||||
const cleanName = tool.name.replace(/^(_)?exec_/, '');
|
||||
const nameLower = cleanName.toLowerCase();
|
||||
const args = getToolArgs(tool);
|
||||
const nativeTitle = getNativeActionTitle(tool.name, args);
|
||||
if (nativeTitle) return nativeTitle;
|
||||
|
||||
switch (nameLower) {
|
||||
case 'manage_app':
|
||||
@@ -899,6 +902,8 @@ export function getToolIcon(tool: any): string {
|
||||
*/
|
||||
export function getToolTitle(tool: any): string {
|
||||
if (!tool || !tool.name) return "工具调用";
|
||||
const nativeTitle = getNativeActionTitle(tool.name, getToolArgs(tool));
|
||||
if (nativeTitle) return nativeTitle;
|
||||
const cleanName = tool.name.replace(/^(_)?exec_/, '');
|
||||
const name = cleanName.toLowerCase();
|
||||
switch (name) {
|
||||
@@ -1191,6 +1196,9 @@ export function getAdbTerminalId(tool: any): string {
|
||||
*/
|
||||
export function getToolGenericDetails(tool: any): string {
|
||||
if (!tool) return '';
|
||||
if (getNativeActionTitle(tool.name || '', getToolArgs(tool))) {
|
||||
return extractToolExtraParams(tool).map(param => `${param.key}:${param.value}`).join(',');
|
||||
}
|
||||
if (isAdbCommandTool(tool)) {
|
||||
return '';
|
||||
}
|
||||
@@ -1235,7 +1243,9 @@ export function getToolErrorMessage(tool: any): string {
|
||||
*/
|
||||
export function extractToolExtraParams(toolData: any, cache?: WeakMap<any, ActionParam[]>): ActionParam[] {
|
||||
if (!toolData) return [];
|
||||
const payload = toolData.payload || toolData.args || toolData;
|
||||
const nativeArgs = getNativeActionArgs(getToolArgs(toolData));
|
||||
const native = getNativeActionTitle(toolData.name || '', nativeArgs) !== null;
|
||||
const payload = native ? nativeArgs : (toolData.payload || toolData.args || toolData);
|
||||
if (!payload || typeof payload !== 'object') return [];
|
||||
if (cache && cache.has(payload)) {
|
||||
return cache.get(payload)!;
|
||||
@@ -1272,11 +1282,16 @@ export function extractToolExtraParams(toolData: any, cache?: WeakMap<any, Actio
|
||||
}
|
||||
|
||||
const mergedObj = { ...toolData, ...(typeof payload === 'object' ? payload : {}), ...parsedArgs };
|
||||
if (native) Object.assign(mergedObj, nativeArgs);
|
||||
|
||||
for (const [k, v] of Object.entries(mergedObj)) {
|
||||
const lowerK = k.toLowerCase();
|
||||
if (standardKeys.has(lowerK)) continue;
|
||||
if (standardKeys.has(lowerK) && !(native && lowerK === 'target')) continue;
|
||||
if (v === null || v === undefined || v === '') continue;
|
||||
if (native) {
|
||||
result.push(formatNativeActionParam(k, v));
|
||||
continue;
|
||||
}
|
||||
|
||||
let valStr = String(v);
|
||||
if (valStr.includes('object at 0x') || valStr.startsWith('<artemis.') || valStr.includes('<controller') || valStr.includes('<android_world')) continue;
|
||||
|
||||
Reference in new issue
Block a user