// 无头对拍: 参数化引擎 (一期 2026-08-25) — 网页 UI 与桌面 selftest 读同一份 parametric-vectors.json // 用法: node tools\e2e-parametric.js [siteUrl] [vectorsPath] // 断言: 标准档位编码/下载可用; 非标档位编码+徽标+下载置灰+最近标准档位提示; 非法组合无型号 const fs = require('fs'); let chromium; try { chromium = require('playwright-core').chromium; } catch (e) { chromium = require('C:/Users/ruigu/AppData/Local/Temp/nds3dtest/node_modules/playwright-core').chromium; } const edge = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'; (async () => { const url = process.argv[2] || 'http://localhost:8080/'; const vecPath = process.argv[3] || 'w:/团队文件-TeamCenter/airtac_0602_2026_v11sp5_world_version/airtac_0602_2026_v11sp5_world_version/OnebotCatalog/onebot-data/catalog/parametric-vectors.json'; const vec = JSON.parse(fs.readFileSync(vecPath, 'utf8')); const browser = await chromium.launch({ executablePath: edge, headless: true }); const page = await browser.newPage({ viewport: { width: 1600, height: 900 } }); const errs = []; page.on('pageerror', e => errs.push(e.message)); await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('.tree .series', { timeout: 20000 }); // 等目录树渲染 (服务器冷启动首请求慢) await page.waitForTimeout(800); let pass = 0, fail = 0; const out = []; const check = (name, ok) => { out.push((ok ? 'PASS ' : 'FAIL ') + name); if (ok) pass++; else fail++; }; // 选中 KC 系列 (首个参数化系列) const names = await page.$$eval('.tree .series', els => els.map(e => e.textContent.trim())); const kcIdx = names.findIndex(n => n.indexOf('KC') === 0); if (kcIdx < 0) { console.log('FAIL: 树中未找到 KC 系列'); await browser.close(); process.exit(1); } const seriesEls = await page.$$('.tree .series'); await seriesEls[kcIdx].click(); await page.waitForTimeout(500); for (let ci = 0; ci < vec.cases.length; ci++) { const c = vec.cases[ci]; const tag = 'case' + (ci + 1) + ' ' + c.series; const keys = Object.keys(c.params); const setParam = async (p, v) => { const sel = await page.$('#sel-' + p); if (!sel) { check(tag + ' 参数控件缺失 ' + p, false); return; } const isSelect = (await sel.evaluate(el => el.tagName)) === 'SELECT'; console.log(' [' + tag + '] 设置 ' + p + '=' + JSON.stringify(v) + ' (' + (isSelect ? 'select' : 'input') + ')'); if (isSelect) { const val = String(v) === '' ? '__EMPTY__' : String(v); await sel.selectOption(val); } else { await sel.fill(String(v)); await sel.dispatchEvent('change'); } await page.waitForTimeout(200); }; // 错误用例: 引擎层拒绝已由桌面向量对拍覆盖; 网页端断言 UI 联动拦截行为 if (c.expectError === 'rule') { // 末参数 = 违背规则的那个: 先设置其余参数, 该选项应已被联动剔除 for (let k = 0; k < keys.length - 1; k++) await setParam(keys[k], c.params[keys[k]]); await page.waitForTimeout(300); const lastP = keys[keys.length - 1]; const lastV = String(c.params[lastP]); const opts = await page.$$eval('#sel-' + lastP + ' option', os => os.map(o => o.value)); check(tag + ' 规则联动剔除非法选项 ' + lastP + '=' + lastV + ' (实际选项: ' + opts.join(',') + ')', opts.indexOf(lastV) < 0); continue; } if (c.expectError === 'domain') { for (let k = 0; k < keys.length - 1; k++) await setParam(keys[k], c.params[keys[k]]); await page.waitForTimeout(300); const lastP = keys[keys.length - 1]; await setParam(lastP, c.params[lastP]); // 输入越界值 → 处理函数拒绝 await page.waitForTimeout(300); const title = await page.$eval('#modelTitle', el => el.textContent); const status = await page.$eval('#status', el => el.textContent); check(tag + ' 域外输入被拒绝 (标题: ' + title.trim() + ' | 状态: ' + status.trim() + ')', title.indexOf('还缺') >= 0 || status.indexOf('超出范围') >= 0); continue; } // 依次设置各参数 (枚举用 select; range 用输入框 + change) for (const p of keys) await setParam(p, c.params[p]); await page.waitForTimeout(400); const title = await page.$eval('#modelTitle', el => el.textContent); if (c.expectError) { const noModel = title.indexOf('KC') < 0 || title.indexOf('还缺') >= 0 || title.indexOf('无对应型号') >= 0; check(tag + ' 非法组合不生成型号 (标题: ' + title.trim() + ')', noModel); continue; } const hasCode = title.indexOf(c.expectCode) >= 0; check(tag + ' 型号编码 ' + c.expectCode + ' (标题: ' + title.trim() + ')', hasCode); if (c.expectStandard) { const dlOk = await page.$eval('#btnDownload', el => !el.classList.contains('hidden') && !el.disabled); check(tag + ' 标准档位下载可用', dlOk); } else { const badge = await page.$eval('#modelTitle', el => !!el.querySelector('.nonstd-badge')); // 生成服务未配置 → 置灰(一期过渡态); 已配置 → 在线生成按钮可用 (两种都是正确状态) const dl = await page.$eval('#btnDownload', el => ({ hidden: el.classList.contains('hidden'), disabled: el.disabled, text: el.textContent })); const dlOk = !dl.hidden && ((dl.disabled && dl.text.indexOf('下载 STEP') >= 0) || (!dl.disabled && dl.text.indexOf('在线生成') >= 0)); check(tag + ' 非标徽标显示 + 下载状态正确 (按钮: ' + dl.text + (dl.disabled ? '/disabled' : '/enabled') + ')', badge && dlOk); if (c.expectNearest) { const hint = await page.$eval('#viewer3dHint', el => el.textContent); check(tag + ' 最近标准档位提示 ' + c.expectNearest + ' (提示: ' + hint.trim() + ')', hint.indexOf(c.expectNearest) >= 0); } } } // 非标加入购物车 → 角标 + 抽屉标注 (与桌面语义一致) await page.fill('#sel-stroke', '87'); await page.dispatchEvent('#sel-stroke', 'change'); await page.waitForTimeout(400); await page.click('#btnAddCart'); await page.waitForTimeout(300); await page.click('#btnCart'); await page.waitForTimeout(300); const sub = await page.$eval('#cartList .cart-item .ci-sub', el => el.textContent); check('购物车非标条目标注 (内容: ' + sub.trim() + ')', sub.indexOf('非标') >= 0); await page.click('#cartClose'); await page.waitForTimeout(200); // 标准档位回归: 换回 stroke=75 → 下载恢复可用 await page.fill('#sel-stroke', '75'); await page.dispatchEvent('#sel-stroke', 'change'); await page.waitForTimeout(400); const dlRe = await page.$eval('#btnDownload', el => !el.disabled && el.getAttribute('href').indexOf('.step') >= 0); check('标准档位回归: 下载恢复可用 (href=' + (await page.$eval('#btnDownload', el => el.getAttribute('href'))) + ')', dlRe); console.log(out.join('\n')); console.log('结果: ' + pass + ' 通过, ' + fail + ' 失败 | 页面错误: ' + (errs.length ? errs.join(' | ') : '无')); await browser.close(); process.exit(fail ? 1 : 0); })().catch(e => { console.error('FAIL:', e.message); process.exit(1); });