Files
tripsense/tools/verify-step2.cjs
T
tamakyi 7ad86c6619 归属判定升级:DataV 官方行政区划表 + 同名县消歧
用阿里 DataV GeoAtlas(免密钥)替换 OSM Overpass 离线县表:
- 民政部 adcode 体系,县级前 4 位即所属地级码,2832 县 100% 可定位地级
  (OSM 表只有 1182/2362,且 admin_level 层级不可靠)
- 同名县(全国 30 组)保留全部候选,运行时按坐标消歧,
  修掉「柳州的城中区被归到西宁市」

归属判定改为「按名字精确查官方表」优先于「按中心点猜最近的县」:
窄长县按中心点会跨省错判(环江喀斯特实际在环江县,按中心点判到荔波县)
查表带距离校验,精确全称命中放宽到 150km(狭长县中心离边界可达 85km+)

修掉三个真实缺陷:
- Overpass 兜底把地级塞进 osmCounty、县名塞进 osmCity,
  与 attachPlaces「先找县级」的顺序相反,导致兜底路径永远丢下层县名
- Overpass 请求无硬超时,公共实例「连着不返回」能把一次分析拖成十几分钟;
  补三层限(单次超时 / 阶段总时限 / is_in 反查次数上限),13 分钟 → 48 秒
- Photon 的 osm.place.city 类目混着景点 POI,kind==='城市' 时无条件把名字
  当地名,导致「社更穿洞北门」「柳州之门」等景点自己成一组;
  改为必须同时是行政区名且能在官方表查到

新增离线回归测试(都不打外部网络,Photon/Overpass 挂掉也能回归):
- test-admin-lookup:同名县消歧、已知边界点、狭长县远端点(19 项)
- test-overpass-fallback:本地假 Overpass,验兜底路径、简称归一、200+HTML 错误页
- test-grouping-regression:本地假 Photon,20 候选完整分组
- verify-ui-full:端到端 UI 验收(真实无头 Chrome 读真实 DOM)
2026-11-06 02:05:24 +08:00

179 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* 第二步验收:分组渲染 / 折叠 / 搜索过滤 / 筛选提示条。
* 走 CDP 打开真实页面,注入一份候选数据(Photon 公共实例不稳,不依赖它),
* 然后核对 DOM 结构并截图到 preview/。
*/
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const OUT = path.join(__dirname, '..', 'preview');
const BASE = process.env.BASE || 'http://127.0.0.1:8787';
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const PORT = 9345;
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 }));
});
}
}
async function getWsUrl() {
const res = await fetch(`http://127.0.0.1:${PORT}/json/version`);
return (await res.json()).webSocketDebuggerUrl;
}
(async () => {
// 启动 headless Chrome(带调试端口)
const chrome = spawn(CHROME, [
'--headless=new', '--disable-gpu', '--hide-scrollbars',
`--remote-debugging-port=${PORT}`,
'--user-data-dir=' + path.join(require('os').tmpdir(), 'tripverify' + Date.now()),
'about:blank',
], { stdio: 'ignore' });
let wsUrl = null;
for (let i = 0; i < 30 && !wsUrl; i += 1) {
await sleep(500);
try { wsUrl = await getWsUrl(); } 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 = (m, p) => browser.send(m, p, 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, 400));
return r.result?.result?.value;
};
// 注入候选数据(覆盖三个地名的多个景点),直接驱动 renderCards
await evalJs(`(() => {
const mk = (id, name, place, placeKey, placeDetail, score, km, kind) => ({
id, name, kind, place, placeKey, placeDetail, score, verdict: score >= 60 ? '推荐' : '可考虑',
distanceKm: km, bearingDeg: 45, tags: ['自然风光'], region: '',
breakdown: { weather: 20, time: 20, interest: 10 },
reasons: ['风景好'], warnings: [],
weather: { summary: { days: 3, dominant: { icon: '☀️', label: '晴' }, avgHigh: 26, avgLow: 16, maxPrecipProb: 20, totalPrecip: 0, maxWind: 8 } },
road: { durationMinutes: 120, distanceKm: km, source: 'OSRM' },
rail: { available: false },
aqi: 42, aqiInfo: { label: '优' },
});
state.analysis = {
origin: { label: '广西壮族自治区 · 河池市 · 金城江区' },
timeRange: { start: '2026-11-06', end: '2026-11-08', days: 3, weekdayHint: '周五至周日' },
constraints: { radiusKm: 150, excludeLocal: true },
summary: {}, stats: {}, dataNotes: [],
candidates: [
mk('a1', '荔波小七孔', '黔南州', '黔南', '荔波县', 78, 77, '风景区'),
mk('a2', '茂兰喀斯特森林', '黔南州', '黔南', '荔波县', 72, 80, '风景区'),
mk('a3', '邓恩铭故居', '黔南州', '黔南', '荔波县', 61, 82, '博物馆'),
mk('b1', '加榜梯田', '黔东南州', '黔东南', '从江县', 75, 165, '风景区'),
mk('b2', '西江千户苗寨', '黔东南州', '黔东南', '雷山县', 70, 199, '古村'),
mk('c1', '柳州古城', '柳州市', '柳州', '城中区', 66, 141, '古迹'),
mk('c2', '融水双龙沟', '柳州市', '柳州', '融水苗族自治县', 64, 125, '风景区'),
],
};
gotoStep(2);
renderStep2();
renderCards();
return true;
})()`);
await sleep(1200);
// 1) 分组结构
const groups = await evalJs(`(() => {
return [...document.querySelectorAll('#cards .place-group')].map(g => ({
key: g.dataset.place,
head: g.querySelector('.place-head').innerText.replace(/\\n/g, ' | '),
items: [...g.querySelectorAll('.dest-card .card-title h4')].map(x => x.textContent),
}));
})()`);
console.log('=== 分组 ===');
for (const g of groups) console.log(' ', g.key, '→', g.items.join(', '), '||', g.head);
const cardCount = await evalJs(`document.querySelectorAll('#cards .dest-card').length`);
console.log('卡片数 =', cardCount, '(应为 7)');
// 2) 搜索过滤
await evalJs(`(() => { const i = document.querySelector('#candSearch'); i.value='溶洞'; i.dispatchEvent(new Event('input',{bubbles:true})); return true; })()`);
await sleep(500);
console.log('搜索「溶洞」后卡片数 =', await evalJs(`document.querySelectorAll('#cards .dest-card').length`));
console.log('提示条 =', await evalJs(`document.querySelector('#filterNote')?.innerText`));
await evalJs(`(() => { const i = document.querySelector('#candSearch'); i.value='柳州'; i.dispatchEvent(new Event('input',{bubbles:true})); return true; })()`);
await sleep(500);
console.log('搜索「柳州」后卡片数 =', await evalJs(`document.querySelectorAll('#cards .dest-card').length`));
console.log('分组数 =', await evalJs(`document.querySelectorAll('#cards .place-group').length`));
// 无匹配 → 空态
await evalJs(`(() => { const i = document.querySelector('#candSearch'); i.value='不存在的关键词xyz'; i.dispatchEvent(new Event('input',{bubbles:true})); return true; })()`);
await sleep(500);
console.log('无匹配空态 =', await evalJs(`document.querySelector('#cards .empty-mini')?.innerText`));
// 清空
await evalJs(`document.querySelector('#candSearchClear').click()`);
await sleep(500);
console.log('清空后卡片数 =', await evalJs(`document.querySelectorAll('#cards .dest-card').length`));
// 3) 折叠
await evalJs(`document.querySelectorAll('#cards [data-place-toggle]')[0].click()`);
await sleep(400);
console.log('折叠后首组 visible =', await evalJs(`(() => { const g=document.querySelector('#cards .place-group'); return {collapsed: g.querySelector('.place-items').classList.contains('collapsed'), caret: g.querySelector('.place-caret').textContent, aria: g.querySelector('.place-head').getAttribute('aria-expanded')}; })()`));
await evalJs(`document.querySelectorAll('#cards [data-place-toggle]')[0].click()`);
await sleep(400);
console.log('展开后 collapsed =', await evalJs(`document.querySelector('#cards .place-group .place-items').classList.contains('collapsed')`));
// 4) 平铺模式
await evalJs(`(() => { const c = document.querySelector('#groupByPlace'); c.checked = false; c.dispatchEvent(new Event('change',{bubbles:true})); return true; })()`);
await sleep(500);
console.log('平铺模式:分组数 =', await evalJs(`document.querySelectorAll('#cards .place-group').length`), '卡片数 =', await evalJs(`document.querySelectorAll('#cards .dest-card').length`));
await evalJs(`(() => { const c = document.querySelector('#groupByPlace'); c.checked = true; c.dispatchEvent(new Event('change',{bubbles:true})); return true; })()`);
await sleep(500);
// 截图:第二步整块
const box = await evalJs(`(() => {
const el = document.querySelector('#panel2');
const r = el.getBoundingClientRect();
return { x: r.x + window.scrollX, y: r.y + window.scrollY, width: r.width, height: Math.min(r.height, 2200) };
})()`);
const shot = await S('Page.captureScreenshot', {
format: 'png', captureBeyondViewport: true,
clip: { x: box.x, y: box.y, width: box.width, height: box.height, scale: 1 },
});
if (!shot.result?.data) throw new Error('no screenshot: ' + JSON.stringify(shot).slice(0, 300));
fs.writeFileSync(path.join(OUT, 'preview-step2-groups.png'), Buffer.from(shot.result.data, 'base64'));
console.log('截图已保存 preview/preview-step2-groups.png');
browser.ws.close();
chrome.kill();
process.exit(0);
})().catch((e) => { console.error('FAIL', e.message); process.exit(1); });