'use strict'; /** * 端到端验收:AI 追加轮之后「行程路线示意」不能只剩一个点。 * * 用真实无头 Chrome 打开站点,注入一份 mock 分析结果与两版方案正文, * 直接调用页面里的 refreshRouteFromText / drawRouteMap,读真实 DOM: * - 初版:多点点 * - 复现修复前(nearby 清空、不继承):只剩 1 个 * - 修复后(nearby 沿用 + 继承上一版坐标):恢复多点 * - 切换回第 1 版:路线图跟着版本重绘 * * 用法:node tools/verify-route-map-followup.cjs */ const { spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); const os = require('os'); const OUT = path.join(__dirname, '..', 'preview'); const CDP_PORT = 9361; const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe'; function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } class CDP { constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); } static async connect(url) { const ws = new WebSocket(url); await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); const c = new CDP(ws); ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && c.waits.has(m.id)) { c.waits.get(m.id)(m); c.waits.delete(m.id); } }; return c; } send(method, params = {}, sessionId) { const id = ++this.id; return new Promise((res) => { this.waits.set(id, res); this.ws.send(JSON.stringify({ id, method, params, sessionId })); }); } } const LAT = 24.32; const LON = 109.42; const pt = (name, i) => ({ name, lat: +(LAT + (i % 5) * 0.004).toFixed(4), lon: +(LON + Math.floor(i / 5) * 0.004).toFixed(4), type: '地点', }); const SIGHT_NAMES = ['龙潭公园', '马鞍山公园', '窑埠古镇', '柳侯公园', '程阳八寨景区']; const FOOD_NAMES = ['螺蛳粉老店', '青云菜市', '五星步行街', '水上公交码头', '龙潭公园餐厅']; const NEARBY = { area: '柳州市', radiusKm: 8, sights: SIGHT_NAMES.map((n, i) => pt(n, i)), food: FOOD_NAMES.map((n, i) => pt(n, i + 5)), }; const ANALYSIS = { origin: { name: '河池市金城江区', lat: 24.69562, lon: 108.08091 }, candidates: [{ id: 'd1', name: '柳州市', lat: LAT, lon: LON, score: 9.1 }], timeRange: { start: '2026-11-20', end: '2026-11-21', days: 2 }, dataNotes: [], }; const V1 = `# 柳州两日行程 ## 方案总览 两天都在柳州市区与近郊,节奏适中。 ## 逐日安排 ### 第 1 天 上午去 **龙潭公园**、**马鞍山公园**,中午在 **螺蛳粉老店** 吃饭; 下午逛 **窑埠古镇**、**柳侯公园**,傍晚到 **五星步行街** 与 **青云菜市**。 ### 第 2 天 去 **程阳八寨景区**、**水上公交码头**,晚上在 **龙潭公园餐厅** 收尾。 `; const V2 = `# 柳州两日行程(第 2 版:少走路) ## 方案总览 按「少走路」重排:第 1 天压缩成市区一圈,第 2 天只留一个远点。 ## 逐日安排 ### 第 1 天 上午去龙潭公园、马鞍山公园,中午在螺蛳粉老店吃饭; 下午逛窑埠古镇与柳侯公园,傍晚到五星步行街。 ### 第 2 天 只保留程阳八寨景区,删掉水上公交码头与青云菜市(走路太多)。 `; let server = null; let chrome = null; (async () => { if (!fs.existsSync(CHROME)) { console.error('找不到 Chrome:' + CHROME); process.exit(1); } fs.mkdirSync(OUT, { recursive: true }); // ---- 服务端(端口可能被占用自动顺延,从启动日志里读真实端口) server = spawn(process.execPath, ['server.js'], { cwd: path.join(__dirname, '..'), stdio: ['ignore', 'pipe', 'pipe'], }); let banner = ''; server.stdout.on('data', (d) => { banner += d.toString(); }); server.stderr.on('data', (d) => { banner += d.toString(); }); await sleep(2800); const m = banner.match(/http:\/\/127\.0\.0\.1:(\d+)/); if (!m) { console.error('服务端未打印监听地址:' + banner); process.exit(1); } const base = `http://127.0.0.1:${m[1]}`; console.log('服务端 =', base); // ---- 无头 Chrome + CDP chrome = spawn(CHROME, [ '--headless=new', '--disable-gpu', '--hide-scrollbars', `--remote-debugging-port=${CDP_PORT}`, '--user-data-dir=' + path.join(os.tmpdir(), 'tripverifyroute' + Date.now()), 'about:blank', ], { stdio: 'ignore' }); let wsUrl = null; for (let i = 0; i < 30 && !wsUrl; i += 1) { await sleep(500); try { const res = await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`); wsUrl = (await res.json()).webSocketDebuggerUrl; } catch (e) { /* 还没起来 */ } } if (!wsUrl) { console.error('Chrome 未启动'); process.exit(1); } const browser = await CDP.connect(wsUrl); const { result: { targetId } } = await browser.send('Target.createTarget', { url: 'about:blank' }); const { result: { sessionId } } = await browser.send('Target.attachToTarget', { targetId, flatten: true }); const S = (method, params) => browser.send(method, params, sessionId); await S('Page.enable'); await S('Runtime.enable'); await S('Emulation.setDeviceMetricsOverride', { width: 1280, height: 1400, deviceScaleFactor: 1, mobile: false }); await S('Page.navigate', { url: base }); await sleep(2500); const evalJs = async (expr) => { const r = await S('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }); if (r.result?.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails).slice(0, 500)); return r.result?.result?.value; }; // 初始化 mock 状态;强制走「方位示意」分支,保证 SVG 里能数到编号点 await evalJs(`(() => { state.analysis = ${JSON.stringify(ANALYSIS)}; state.selected = new Set(['d1']); state.nearby = ${JSON.stringify(NEARBY)}; state.ai.amapKey = ''; if (!state.config) state.config = {}; state.config.features = { ...(state.config.features || {}), amapRoad: false }; if (typeof gotoStep === 'function') gotoStep(3); window.__V1 = ${JSON.stringify(V1)}; window.__V2 = ${JSON.stringify(V2)}; window.__ver1 = { n: 1, text: window.__V1, routePoints: null, routeMissing: [] }; window.__ver2 = { n: 2, text: window.__V2, routePoints: null, routeMissing: [] }; return true; })()`); const readMap = `(() => ({ points: (state.routePoints || []).length, dots: document.querySelectorAll('#routeMap .rm-idx').length, note: document.querySelector('#routeNote')?.textContent || '', hasSvg: !!document.querySelector('#routeMap .route-svg'), }))()`; // 1) 初版 const first = await evalJs(`(async () => { await refreshRouteFromText(window.__V1, null, window.__ver1); await drawRouteMap(); return ${readMap}; })()`); console.log('初版 →', first); // 2) 复现修复前:追加轮把 nearby 清空、不继承上一版坐标 const broken = await evalJs(`(async () => { state.nearby = null; await refreshRouteFromText(window.__V2, null, null); await drawRouteMap(); return ${readMap}; })()`); console.log('修复前追加轮 →', broken); // 3) 修复后:nearby 沿用 + 继承上一版坐标 const fixed = await evalJs(`(async () => { state.nearby = ${JSON.stringify(NEARBY)}; await refreshRouteFromText(window.__V2, window.__ver1.routePoints, window.__ver2); await drawRouteMap(); return ${readMap}; })()`); console.log('修复后追加轮 →', fixed); // 4) 通过真实下拉事件切回第 1 版,路线图应跟着重绘 await evalJs(`(() => { state.planVersions = [window.__ver1, window.__ver2]; state.viewing = 1; renderVersionBar(); const sel = document.querySelector('#versionSelect'); sel.value = 'v0'; sel.dispatchEvent(new Event('change', { bubbles: true })); return true; })()`); await sleep(400); const switched = await evalJs(readMap); console.log('切回第 1 版 →', switched); // 截图:路线图区域 const box = await evalJs(`(() => { const r = document.querySelector('#routeMap').getBoundingClientRect(); return { x: r.x + scrollX, y: r.y + scrollY, width: Math.max(r.width, 640), height: Math.max(r.height, 380) }; })()`); const shot = await S('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { ...box, scale: 1 }, }); const shotPath = path.join(OUT, 'preview-route-followup.png'); fs.writeFileSync(shotPath, Buffer.from(shot.result.data, 'base64')); console.log('截图已保存 preview/preview-route-followup.png'); const fails = []; if (first.dots < 2) fails.push('初版路线图点数不足'); if (broken.dots !== 1) fails.push('未能复现「只剩一个点」(期望 1,实际 ' + broken.dots + ')'); if (fixed.dots < 2) fails.push('修复后追加轮仍只有一个点'); if (fixed.points <= broken.points) fails.push('修复后路线点数没有增加'); if (switched.dots < 2) fails.push('切换版本后路线图未重绘'); if (fails.length) { console.error('\n失败:' + fails.join(';')); process.exitCode = 1; } else { console.log('\n✅ 端到端验收通过:追加轮仍有多个点,版本切换会重绘'); } })().catch((e) => { console.error(e.stack || e.message); process.exitCode = 1; }).finally(() => { if (chrome) { try { chrome.kill(); } catch (e) { /* ignore */ } } if (server) { try { server.kill(); } catch (e) { /* ignore */ } } });