Files
tripsense/tools/test-overpass-fallback.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

140 lines
6.3 KiB
JavaScript

'use strict';
/**
* Overpass 兜底路径验证(Photon 全挂场景)。
*
* 起一个**本地假 Overpass**(识别 query 里的特征串返回不同数据),
* 把 config.ENDPOINTS.overpass 指过去,验证:
* 1. Photon 全挂时确实走 Overpass 兜底
* 2. Overpass 候选的归属正确(环江喀斯特 → 河池市/环江县,不跑到荔波县)
* 3. is_in 反查结果经过统一查表归一(OSM 简称 → 标准县全称)
* 4. Overpass 返回 200 但内容是 HTML 错误页时,要被识别为失败而不是当数据解析
*/
const http = require('http');
const path = require('path');
const SRC = path.join(__dirname, '..', 'lib', 'sources.js');
const ORIGIN = { lat: 24.69562, lon: 108.08091 };
// 假 Overpass:按 query 特征分发
const ADMIN_ISIN = JSON.stringify({
version: 0.6,
elements: [
{ type: 'relation', id: 1, tags: { admin_level: '4', name: '广西壮族自治区' } },
{ type: 'relation', id: 2, tags: { admin_level: '5', name: '河池市' } },
{ type: 'relation', id: 3, tags: { admin_level: '6', name: '环江毛南族自治县' } },
],
});
// 行政区别名:故意用**简称**「环江」,验证会过查表归一成全称
const ADMIN_ISIN_SHORT = JSON.stringify({
version: 0.6,
elements: [
{ type: 'relation', id: 2, tags: { admin_level: '5', name: '河池市' } },
{ type: 'relation', id: 3, tags: { admin_level: '6', name: '环江' } },
],
});
// POI:环江喀斯特(25.1, 108.28,环江县境内)
const POIS = JSON.stringify({
version: 0.6,
elements: [
{ type: 'node', id: 11, lat: 25.10, lon: 108.28, tags: { name: '环江喀斯特世界自然遗产-核心功能区', tourism: 'attraction' } },
{ type: 'node', id: 12, lat: 25.24, lon: 107.90, tags: { name: '小七孔古镇', tourism: 'attraction' } },
],
});
const HTML_ERROR = '<!DOCTYPE html><html><body>Error : runtime error: duplicate_query</body></html>';
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
const q = decodeURIComponent(body.replace(/^data=/, ''));
const send = (txt, code = 200) => {
res.writeHead(code, { 'content-type': 'application/json' });
res.end(txt);
};
if (q.includes('is_in')) {
// 25.10,108.28 用全称;25.24,107.90 用简称(测试归一)
if (q.includes('25.10000,108.28000')) return send(ADMIN_ISIN);
if (q.includes('25.24000,107.90000')) return send(ADMIN_ISIN_SHORT);
return send(JSON.stringify({ version: 0.6, elements: [] }));
}
if (q.includes('"tourism"')) {
// 第二次尝试时返回 HTML 错误页(测「200 但不是 JSON」)
if (process.env.HTML_MODE === '1') return send(HTML_ERROR);
return send(POIS);
}
if (q.includes('admin_level')) {
return send(JSON.stringify({ version: 0.6, elements: [] }));
}
send(JSON.stringify({ version: 0.6, elements: [] }));
});
});
server.listen(0, '127.0.0.1', async () => {
const port = server.address().port;
const cfg = {
ENDPOINTS: {
overpass: [`http://127.0.0.1:${port}/api/interpreter`],
photon: 'http://127.0.0.1:1/api', // 必然失败,强制走兜底
reverse: 'http://127.0.0.1:1/reverse',
amapAround: '', amapGeocode: '', amapPlaceText: '', amapRegeo: '',
amapDriving: '', amapStaticMap: '',
},
AMAP_KEY: '',
SEARCH: { maxRadiusKm: 200, maxCandidates: 20 },
PHOTON: { categories: [], concurrency: 1, perCategory: 2, timeoutMs: 500, retries: 0 },
};
// 强制清掉模块缓存,让新的 cfg 生效
for (const k of Object.keys(require.cache)) if (k.includes(path.sep + 'lib' + path.sep)) delete require.cache[k];
const S = require(SRC);
console.log('=== Overpass 兜底路径验证(Photon 全挂)===');
const res = await S.discoverCandidates(ORIGIN, 150, cfg, { excludeLocal: true });
const cands = res.candidates || [];
console.log('候选数 =', cands.length);
console.log('stats =', JSON.stringify(res.stats));
for (const c of cands) {
console.log(` ${c.name} → ${c.place || '(空)'} / ${c.placeDetail || '(空)'} [${c.source}]`);
}
// 断言
const fail = [];
const find = (kw) => cands.find((c) => c.name.includes(kw));
const huanjiang = find('环江喀斯特');
if (!huanjiang) fail.push('环江喀斯特没进候选');
else {
if (huanjiang.place !== '河池市') fail.push(`环江喀斯特地级应为 河池市,实际 ${huanjiang.place}`);
if (huanjiang.placeDetail !== '环江毛南族自治县') fail.push(`环江喀斯特县应为 环江毛南族自治县,实际 ${huanjiang.placeDetail}`);
}
const xiaoqikong = find('小七孔古镇');
if (!xiaoqikong) fail.push('小七孔古镇没进候选');
else if (xiaoqikong.placeDetail !== '环江毛南族自治县') {
// mock 的 is_in 只给了 OSM 简称「环江」,应被查表归一成标准全称
fail.push(`小七孔的县名没归一,实际 ${xiaoqikong.placeDetail}`);
}
for (const c of cands) {
if (!c.place || c.place === '?') fail.push(`${c.name} 分组名为空`);
if (c.placeDetail === '?') fail.push(`${c.name} 下层地名为空`);
}
console.log(fail.length ? '\n❌ 失败:\n ' + fail.join('\n ') : '\n✅ 全部通过');
// 第二组:Overpass 返回 200 但内容是 HTML 错误页(duplicate_query 之类)。
// 这种必须被识别为失败,不能当数据解析出一个空数组还「看起来成功」。
console.log('\n=== Overpass 返回 200 + HTML 错误页 ===');
process.env.HTML_MODE = '1';
// 第二轮必须绕过缓存,否则拿到的是第一轮的结果
for (const k of Object.keys(require.cache)) if (k.includes(path.sep + 'lib' + path.sep)) delete require.cache[k];
const S2 = require(SRC);
const res2 = await S2.discoverCandidates(ORIGIN, 150, cfg, { excludeLocal: true });
const cands2 = res2.candidates || [];
console.log('stats =', JSON.stringify(res2.stats));
const overpassPois = cands2.filter((c) => c.source === 'OpenStreetMap / Overpass');
if (overpassPois.length) fail.push(`HTML 错误页被当成了数据,解析出 ${overpassPois.length} 个假 POI`);
else console.log('✅ HTML 错误页被正确识别为失败,未产生假候选');
delete process.env.HTML_MODE;
server.close();
process.exit(fail.length ? 1 : 0);
});