AI_UIAutomation/scripts/gen-must-test-manifest.ts

270 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 必测项 manifest 生成器
*
* 从 ONES 拉取「必测项-AI自动化」计划(plan CQz9YCNX / lib EPfZfC9Y)
* 生成 test-plan/must-test.manifest.ts —— 作为「ONES ↔ 测试文件 ↔ step 级回写 ↔ 覆盖率」的中间层。
*
* 三类条目:
* - add : 每单品一条「添加X验证」case (主键 ones 用例号)
* - feature : 品类模块内、非添加的单品功能必测 (绑定手持/学习遥控器/keypad密码等)
* - ctrl : 两条协议超级用例的每个 step (主键 ones#step_uuid带 proto)
*
* 用法:
* npx ts-node scripts/gen-must-test-manifest.ts [--plan CQz9YCNX] [--out test-plan/must-test.manifest.ts] [--dry-run]
*
* 详见 prompts/must_test_conversion.md
*/
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { DEVICE_CONFIG } from '../config/device.config';
import { fetchPlanCases } from '../utils/ones-sync';
const ONES_CLI = '/Users/woan/local/bin/ones';
const LIB_UUID = 'EPfZfC9Y'; // App 必测项
const DEFAULT_PLAN = 'CQz9YCNX'; // 必测项-AI自动化
// 控制超级用例: 用例号 -> 协议
const CTRL_CASES: { number: number; proto: 'ble' | 'wifi' }[] = [
{ number: 15975, proto: 'ble' }, // 蓝牙控制设备 (关WiFi/热点、开蓝牙)
{ number: 15974, proto: 'wifi' }, // WiFi控制设备 (开WiFi/热点、关蓝牙)
];
// 单品 add/feature 所在的品类模块 (module_uuid)
const CATEGORY_MODULES = new Set([
'Q77TzNjH', // 摄像头类
'JdPYj5r7', // 灯类&WiFi
'HqxErR84', // 蓝牙类
'TPe3ZhZG', // 开关类
'BDBbZz8f', // URC HUB
'Qmd8bwnW', // 温湿度&hub类
'BpA3ATUg', // Lock类
'Yb6hgZ9T', // 扫地机类
]);
// 关键字 -> 品类(= 测试目录名, snake_case)。按最长命中优先匹配。
const KEYWORD_TO_CATEGORY: [string, string][] = [
['relay switch', 'plug'], ['garage', 'plug'], ['plug mini', 'plug'], ['plugmini', 'plug'], ['eu plug', 'plug'], ['plug', 'plug'],
['smart radiator', 'meter'], ['暖气阀', 'meter'], ['climate panel', 'meter'], ['温湿度', 'meter'], ['meter', 'meter'],
['iosensor', 'sensor'], ['io sensor', 'sensor'], ['motion sensor', 'sensor'], ['contact sensor', 'sensor'], ['人体存在', 'sensor'], ['presence', 'sensor'],
['lock pro', 'lock'], ['lock lite', 'lock'], ['lock ultra', 'lock'], ['lock', 'lock'], ['keypad', 'keypad'],
['blindtil', 'curtain'], ['blind tilt', 'curtain'], ['roller shade', 'curtain'], ['curtain', 'curtain'],
['humidifier', 'humidifier'], ['加湿', 'humidifier'], ['空气净化', 'air_condition'],
['吸顶灯', 'ceiling_light'], ['ceiling', 'ceiling_light'], ['floor lamp', 'ceiling_light'], ['落地灯', 'ceiling_light'], ['candle', 'ceiling_light'], ['融蜡', 'ceiling_light'],
['strip light', 'strip_light'], ['灯带', 'strip_light'], ['霓虹', 'strip_light'], ['neon', 'strip_light'], ['color bulb', 'color_bulb'], ['bulb', 'color_bulb'],
['circulator fan', 'fan'], ['standing circulator', 'fan'], ['落地扇', 'fan'], ['循环扇', 'fan'], ['fan', 'fan'],
['indoor cam', 'camera'], ['pan/tilt', 'camera'], ['ptc', 'camera'], ['doorbell', 'camera'], ['osc', 'osc'], ['cam', 'camera'],
['robot vacuum', 'robot'], ['k10', 'robot'], ['k11', 'robot'], ['s10', 'robot'], ['s20', 'robot'], ['s1', 'robot'], ['扫地机', 'robot'],
['hub mini matter', 'hub'], ['hub mini', 'hub'], ['hub2', 'hub'], ['hub plus', 'hub'], ['hub', 'hub'],
['urc', 'urc'], ['遥控器', 'remote'], ['remote', 'remote'], ['bot', 'bot'],
['safety alarm', 'safety_alarm'],
];
// 品类目录(snake) -> device.config 的 key(camelCase)。未列出者两者相同。
const CAT_TO_CONFIG_KEY: Record<string, string> = {
ceiling_light: 'ceilingLight',
strip_light: 'stripLight',
color_bulb: 'colorBulb',
air_condition: 'airCondition',
};
function classify(text: string): string | null {
const t = text.toLowerCase();
let best: string | null = null;
let bestLen = -1;
for (const [kw, cat] of KEYWORD_TO_CATEGORY) {
if (t.includes(kw) && kw.length > bestLen) {
best = cat;
bestLen = kw.length;
}
}
return best;
}
function deviceFor(cat: string): string {
const key = CAT_TO_CONFIG_KEY[cat] || cat;
return DEVICE_CONFIG[key]?.defaultDevice || '';
}
function folderExists(cat: string): boolean {
return fs.existsSync(path.resolve(__dirname, '..', 'tests', cat));
}
function runOnes(args: string): any {
const output = execSync(`${ONES_CLI} ${args}`, { encoding: 'utf-8', timeout: 30000 });
return JSON.parse(output);
}
// 短描述: 去前缀符号、压空白、截断,用于 testName
function shortDesc(s: string): string {
return s.replace(/\s+/g, ' ').replace(/^[-—\s]+/, '').trim().slice(0, 24);
}
interface AddRow {
kind: 'add' | 'feature';
ones: number;
name: string;
cat: string;
device: string;
file: string;
testName: string;
status: 'todo' | 'na';
}
interface CtrlRow {
kind: 'ctrl';
ones: number;
step: string;
proto: 'ble' | 'wifi';
name: string;
result: string;
cat: string;
device: string;
action: string;
file: string;
testName: string;
status: 'todo' | 'na';
}
type Row = AddRow | CtrlRow;
function main() {
const argv = process.argv.slice(2);
const plan = argVal(argv, '--plan') || DEFAULT_PLAN;
const out = argVal(argv, '--out') || 'test-plan/must-test.manifest.ts';
const dryRun = argv.includes('--dry-run');
console.log(`[gen] 计划成员校验 plan=${plan} ...`);
const planNums = new Set(fetchPlanCases(plan).map((c) => c.caseNumber));
console.log(`[gen] 读取用例库 ${LIB_UUID} ...`);
const libCases: any[] = runOnes(`testcase case list ${LIB_UUID}`).cases || [];
const rows: Row[] = [];
const na: string[] = [];
// 1) add / feature: 品类模块内、在计划内的 case
for (const c of libCases) {
if (!CATEGORY_MODULES.has(c.module_uuid)) continue;
if (!planNums.has(c.number)) continue;
const kind: 'add' | 'feature' = c.name.startsWith('添加') ? 'add' : 'feature';
const cat = classify(c.name);
if (!cat || !folderExists(cat)) {
na.push(`${kind} ONES:${c.number} ${c.name} [${cat || '未识别品类'}${cat && !folderExists(cat) ? '无测试目录' : ''}]`);
rows.push({ kind, ones: c.number, name: c.name, cat: cat || '', device: cat ? deviceFor(cat) : '', file: '', testName: '', status: 'na' });
continue;
}
const suffix = kind === 'add' ? 'connect' : 'control';
const device = deviceFor(cat);
rows.push({
kind,
ones: c.number,
name: c.name,
cat,
device,
file: `tests/${cat}/${cat}_${suffix}.test.ts`,
testName: `[P0][ONES:${c.number}] ${c.name}`,
status: 'todo',
});
}
// 2) ctrl: 两条协议超级用例的每个 step
for (const { number, proto } of CTRL_CASES) {
const found = runOnes(`testcase case search --key ${number}`).cases || [];
const steps: any[] = found[0]?.steps || [];
for (const s of steps) {
const cat = classify(s.desc || '');
if (!cat || !folderExists(cat)) {
na.push(`ctrl[${proto}] ONES:${number}#${s.uuid} ${shortDesc(s.desc || '')} [${cat || '未识别品类'}${cat && !folderExists(cat) ? '无测试目录' : ''}]`);
rows.push({ kind: 'ctrl', ones: number, step: s.uuid, proto, name: s.desc || '', result: s.result || '', cat: cat || '', device: cat ? deviceFor(cat) : '', action: '', file: '', testName: '', status: 'na' });
continue;
}
const device = deviceFor(cat);
rows.push({
kind: 'ctrl',
ones: number,
step: s.uuid,
proto,
name: s.desc || '',
result: s.result || '',
cat,
device,
action: shortDesc(s.desc || ''),
file: `tests/${cat}/${cat}_control.test.ts`,
testName: `[P0][ONES:${number}#${s.uuid}][${proto}] ${shortDesc(s.desc || '')} ${device}`.trim(),
status: 'todo',
});
}
}
printSummary(rows, na, plan);
if (dryRun) {
console.log('\n[gen] --dry-run: 未写文件');
return;
}
const outPath = path.resolve(__dirname, '..', out);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, renderManifest(rows, plan), 'utf-8');
console.log(`\n[gen] 已生成 ${out} (${rows.length} 条)`);
}
function argVal(argv: string[], flag: string): string | undefined {
const i = argv.indexOf(flag);
return i >= 0 ? argv[i + 1] : undefined;
}
function printSummary(rows: Row[], na: string[], plan: string) {
const by = (k: string) => rows.filter((r) => r.kind === k);
const add = by('add'), feat = by('feature'), ctrl = by('ctrl') as CtrlRow[];
console.log(`\n=== 必测项覆盖摘要 (plan ${plan}) ===`);
console.log(`add(添加) : ${add.length}`);
console.log(`feature(功能): ${feat.length}`);
console.log(`ctrl(控制step): ${ctrl.length} (ble ${ctrl.filter((r) => r.proto === 'ble').length} / wifi ${ctrl.filter((r) => r.proto === 'wifi').length})`);
const catCount: Record<string, number> = {};
for (const r of rows) catCount[r.cat || '∅'] = (catCount[r.cat || '∅'] || 0) + 1;
console.log('按品类:', JSON.stringify(catCount));
console.log(`\n=== 未落地 (na, ${na.length}) —— 需人工补关键字/目录或归平台 ===`);
na.forEach((n) => console.log(' ! ' + n));
}
function renderManifest(rows: Row[], plan: string): string {
const header = `// AUTO-GENERATED by scripts/gen-must-test-manifest.ts — do not edit by hand.
// 源: ONES plan ${plan} / lib ${LIB_UUID} (必测项-AI自动化)。重新生成: npm run gen:must-test
// 详见 prompts/must_test_conversion.md
export type MustTestItem =
| {
kind: 'add' | 'feature';
ones: number;
name: string;
cat: string;
device: string;
file: string;
testName: string;
status: 'todo' | 'na' | 'done';
}
| {
kind: 'ctrl';
ones: number;
step: string;
proto: 'ble' | 'wifi';
name: string;
result: string;
cat: string;
device: string;
action: string;
file: string;
testName: string;
status: 'todo' | 'na' | 'done';
};
export const MUST_TEST: MustTestItem[] = [
`;
const body = rows
.map((r) => ' ' + JSON.stringify(r) + ',')
.join('\n');
return header + body + '\n];\n';
}
main();