修复 AI 追加轮后路线示意只剩一个点
根因: runPlan 追加轮把 state.nearby 置 null, payload.nearby 随之为空, 模型第二版不再点名具体地点; extractPlanPlaces 的 nearby 来源(带坐标)空了, 加粗词又无坐标, 无 Key 时全被判 missing -> 只剩 1 个已选目的地。 - 追加轮沿用 state.nearby 并继续放进上下文; 提示词要求保留加粗点名 - extractPlanPlaces(text, inherit) 继承上一版坐标 + 否定语境护栏 - planVersions 存 routePoints, 版本切换/回到初版/载入保存方案同步重绘 - previous 改用 currentPlanText(), drawRouteMap 用 state.routeMissing - 新增 test-route-map-followup.cjs / verify-route-map-followup.cjs 与 README 说明
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
'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);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
'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 */ } }
|
||||
});
|
||||
Reference in New Issue
Block a user