'use strict'; /** * 回归测试:AI 追加轮之后「行程路线示意」不能塌成一个点。 * * 复现路径(修复前): * runPlan 追加轮把 state.nearby 置 null → payload.nearby 也变成 null, * 模型第二版不再点名具体地点;extractPlanPlaces 的 nearby 来源为空, * 加粗词又常被改写成简称 / 不再加粗,无坐标的点全被 resolveRoutePoints 丢掉 → * 只剩「已选目的地」(常常只有 1 个)→ 图上孤零零一个点。 * * 本脚本不启动浏览器:把 app.js 里「地点提取」相关的纯逻辑切出来放进 vm, * 注入 mock state,直接比较初版 / 追加轮两版的路线点数,并断言源码里的修复点。 * * 用法:node tools/test-route-map-followup.cjs */ const fs = require('fs'); const path = require('path'); const vm = require('vm'); const APP = path.join(__dirname, '..', 'public', 'app.js'); const src = fs.readFileSync(APP, 'utf8'); const start = src.indexOf('const PLACE_SUFFIX'); const end = src.indexOf('async function resolveRoutePoints'); if (start < 0 || end < 0 || end <= start) { console.error('app.js 结构变了,切不出 extractPlanPlaces'); process.exit(1); } // 这一段只含常量 + 纯函数,不碰 DOM,可以直接在 vm 里跑 const snippet = src.slice(start, end); // ---------------- 造数据:柳州市周边 10 个可点名地点 + 1 个已选目的地 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 CANDIDATES = [{ id: 'd1', name: '柳州市', lat: LAT, lon: LON, score: 9.1 }]; const IDS = ['d1']; // 初版:模型按提示词加粗点名每一个具体地点 const V1 = `# 柳州两日行程 ## 方案总览 两天都在柳州市区与近郊,节奏适中。 ## 逐日安排 ### 第 1 天 上午去 **龙潭公园**、**马鞍山公园**,中午在 **螺蛳粉老店** 吃饭; 下午逛 **窑埠古镇**、**柳侯公园**,傍晚到 **五星步行街** 与 **青云菜市**。 ### 第 2 天 去 **程阳八寨景区**、**水上公交码头**,晚上在 **龙潭公园餐厅** 收尾。 `; // 追加轮:模型重写完整方案,不再重复加粗点名,并在取舍里明确删掉两个点 const V2 = `# 柳州两日行程(第 2 版:少走路) ## 方案总览 按「少走路」重排:第 1 天压缩成市区一圈,第 2 天只留一个远点。 ## 逐日安排 ### 第 1 天 上午去龙潭公园、马鞍山公园,中午在螺蛳粉老店吃饭; 下午逛窑埠古镇与柳侯公园,傍晚到五星步行街。 ### 第 2 天 只保留程阳八寨景区,删掉水上公交码头与青云菜市(走路太多)。 `; // ---------------- 在 vm 里跑真实前端的提取函数 function runExtract(nearby, text, inherit) { const ctx = vm.createContext({ console }); ctx.__nearby = nearby; ctx.__candidates = CANDIDATES; ctx.__ids = IDS; ctx.__text = text; ctx.__inherit = inherit || null; vm.runInContext(` var state = { nearby: __nearby, analysis: { candidates: __candidates }, selected: new Set(__ids) }; ${snippet} var __result = extractPlanPlaces(__text, __inherit); `, ctx); return ctx.__result; } let total = 0; const fails = []; function check(name, ok, detail) { total += 1; console.log(`${ok ? ' OK ' : ' FAIL'} ${name}${detail !== undefined ? ` -> ${detail}` : ''}`); if (!ok) fails.push(name); } console.log('== AI 追加轮路线点回归 =='); // 1) 初版:nearby 10 + 已选 1 = 11 个点,全部带真实坐标 const v1Points = runExtract(NEARBY, V1, null); check('初版提取到多个地点', v1Points.length >= 10, `${v1Points.length} 个`); check('初版地点全部带坐标', v1Points.every((p) => p.lat != null && p.lon != null), `${v1Points.filter((p) => p.lat == null).length} 个缺坐标`); // 2) 复现修复前 bug:追加轮 nearby 被清空、无继承 -> 只剩 1 个已选目的地 const broken = runExtract(null, V2, null); check('修复前:追加轮塌成 1 个点', broken.length === 1, `${broken.length} 个`); // 3) 修复后:nearby 沿用 + 继承上一版坐标 -> 仍是多点 // 其中两个点被「删掉」明确排除,所以是 9 个而不是 11 个 const fixed = runExtract(NEARBY, V2, v1Points); check('修复后:追加轮仍是多个地点', fixed.length >= 8, `${fixed.length} 个`); check('修复后:追加轮地点带坐标', fixed.filter((p) => p.lat != null).length >= 8, `${fixed.filter((p) => p.lat != null).length} 个带坐标`); // 4) 继承路径单独生效(即使 nearby 也没拿到,坐标不会全丢) const inheritOnly = runExtract(null, V2, v1Points); check('继承路径独立生效', inheritOnly.length >= 5, `${inheritOnly.length} 个`); check('继承点带真实坐标', inheritOnly.every((p) => p.lat != null && p.lon != null), `${inheritOnly.filter((p) => p.lat == null).length} 个缺坐标`); // 5) 明确删掉的地点不被画回图上(nearby 沿用 + 继承两条路径都要排除) const deletedFix = fixed.filter((p) => /水上公交码头|青云菜市/.test(p.name)); const deletedInherit = inheritOnly.filter((p) => /水上公交码头|青云菜市/.test(p.name)); check('「删掉」的地点未被画回图上', deletedFix.length === 0 && deletedInherit.length === 0, `nearby 路径 ${deletedFix.length} 个 / 继承路径 ${deletedInherit.length} 个`); // 6) push 覆盖:正文加粗词只有名字,继承点带坐标 -> 同名点应补上坐标 const V3 = '### 第 1 天\n只去 **龙潭公园** 与 **窑埠古镇**,其余取消。\n'; const covered = runExtract(null, V3, v1Points); const lp = covered.find((p) => p.name === '龙潭公园'); check('同名加粗词被继承点补上坐标', !!lp && lp.lat != null && lp.lon != null, lp ? `lat=${lp.lat}` : '未找到'); // ---------------- 源码级断言:确认修复确实落在 app.js / planner.js console.log('== 源码修复点 =='); check('追加轮沿用 state.nearby', /else if \(state\.nearby && shortlist\.some/.test(src)); check('追加轮底稿用 currentPlanText()', /isFollowUp \? currentPlanText\(\) : ''/.test(src)); check('版本对象存 routePoints', /routePoints: null/.test(src)); check('版本切换同步重绘', /state\.routePoints = ver\.routePoints/.test(src)); check('回到初版同步重绘', /state\.routePoints = first\.routePoints/.test(src)); check('提取支持 inherit', /function extractPlanPlaces\(text, inherit\)/.test(src)); check('否定语境护栏共用', /const negated = \(k\)/.test(src)); const planner = fs.readFileSync(path.join(__dirname, '..', 'lib', 'planner.js'), 'utf8'); check('追加轮提示词要求保留点名地点', /不要退化成「某片区」/.test(planner)); console.log(`\n通过 ${total - fails.length} / 失败 ${fails.length}`); if (fails.length) { console.error('失败项:' + fails.join('、')); process.exit(1); }