/** * 必测项 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 = { 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 = {}; 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();