270 lines
10 KiB
TypeScript
270 lines
10 KiB
TypeScript
/**
|
||
* 必测项 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();
|