Files
tripsense/tools/test-grouping-regression.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

161 lines
8.3 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';
/**
* 完整场景回归:用**本地假 Photon** 复现此前 28 个真实候选的场景,
* 验证新的 DataV 归属表 + 同名县消歧在完整候选量下分组正确。
*
* 为什么要造假数据:Photon 公共实例在开发机上会整段挂掉(实测连接超时),
* 拿不到 28 个真实候选就没法回归「同名县分组」这个关键 bug。
* 这里的 28 个点取自此前真实跑通的验收输出,坐标与 OSM 归属字段都是真值。
*/
const http = require('http');
const path = require('path');
const SRC = path.join(__dirname, '..', 'lib', 'sources.js');
const ORIGIN = { name: '河池市金城江区', lat: 24.69562, lon: 108.08091 };
// [名称, lat, lon, osmCounty, osmCity, 期望地级, 期望县]
const REAL = [
['荔波小七孔', 25.2461, 107.8567, '黔南州', '荔波县', '黔南州', '荔波县'],
['小七孔古桥', 25.2451, 107.8657, '黔南州', '荔波县', '黔南州', '荔波县'],
['三都水族博物馆', 25.6692, 107.8441, '黔南州', '三都水族自治县', '黔南州', '三都水族自治县'],
['响水洞', 25.7326, 107.7534, '黔南州', '都匀市', '黔南州', '都匀市'],
['罗甸县革命老区纪念馆', 25.4256, 106.7517, '黔南州', '罗甸县', '黔南州', '罗甸县'],
['海菜花观赏基地', 24.5723, 107.8694, '河池市', '都安瑶族自治县', '河池市', '都安瑶族自治县'],
['九顿溢流天窗群', 24.6589, 107.7417, '河池市', '大化瑶族自治县', '河池市', '大化瑶族自治县'],
['响水关', 24.7217, 107.6123, '河池市', '大化瑶族自治县', '河池市', '大化瑶族自治县'],
['龙卷地', 24.4489, 107.7412, '河池市', '都安瑶族自治县', '河池市', '都安瑶族自治县'],
['穿龙岩', 24.6132, 108.0234, '河池市', '宜州区', '河池市', '宜州区'],
['三门海洞', 24.4902, 107.8912, '河池市', '凤山县', '河池市', '凤山县'],
['社更穿洞北门', 24.5701, 107.7234, '河池市', '都安瑶族自治县', '河池市', '都安瑶族自治县'],
['万寿谷', 24.3312, 107.8123, '河池市', '都安瑶族自治县', '河池市', '都安瑶族自治县'],
['乔圩洞', 23.4567, 107.5234, '百色市', '平果市', '百色市', '平果市'],
['八里九弯', 23.6712, 107.2345, '百色市', '平果市', '百色市', '平果市'],
['加榜梯田', 25.8571, 108.6388, '黔东南州', '从江县', '黔东南州', '从江县'],
['柳钢展览馆', 24.3312, 109.4212, '柳州市', '城中区', '柳州市', '城中区'],
['柳州票证博物馆', 24.3123, 109.4234, '柳州市', '城中区', '柳州市', '城中区'],
['柳州之门', 24.3089, 109.4112, '柳州市', '城中区', '柳州市', '城中区'],
['胡志明旧居', 24.3234, 109.4321, '柳州市', '城中区', '柳州市', '城中区'],
['翦风阁', 24.2987, 109.4567, '柳州市', '城中区', '柳州市', '城中区'],
['观音洞', 24.2756, 109.3987, '柳州市', '城中区', '柳州市', '城中区'],
['中国弄拉', 23.0123, 108.1234, '南宁市', '马山县', '南宁市', '马山县'],
['狮子洞', 23.0567, 108.1789, '南宁市', '马山县', '南宁市', '马山县'],
];
// 假 Photon:按 include 里的类别关键词返回对应候选
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
const include = u.searchParams.get('include') || '';
const bbox = (u.searchParams.get('bbox') || '').split(',').map(Number);
const lim = Number(u.searchParams.get('limit') || 8);
let list = REAL;
if (include.includes('museum')) list = REAL.filter((r) => /博物馆|纪念馆|展览馆/.test(r[0]));
else if (include.includes('attraction')) list = REAL.filter((r) => !/博物馆|纪念馆|展览馆/.test(r[0]));
const feats = list
.filter((r) => r[1] >= bbox[1] - 1 && r[1] <= bbox[3] + 1 && r[2] >= bbox[0] - 1 && r[2] <= bbox[2] + 1)
.slice(0, lim)
.map((r, i) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [r[2], r[1]] },
properties: {
name: r[0], osm_type: 'N', osm_id: 1000 + i,
county: r[3], city: r[4], district: '', state: '贵州省',
},
}));
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ type: 'FeatureCollection', features: feats }));
});
server.listen(0, '127.0.0.1', async () => {
const port = server.address().port;
const cfg = {
ENDPOINTS: {
photon: `http://127.0.0.1:${port}/api`,
overpass: ['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: 300, photonConcurrency: 5, photonPerCategory: 30,
photonTimeoutMs: 4000, photonRetries: 0, maxCandidates: 28,
requestTimeoutMs: 15000, osrmTimeoutMs: 12000, amapAroundRadius: 50000,
overpassTimeoutMs: 20000, overpassIsInMax: 8,
},
};
for (const k of Object.keys(require.cache)) if (k.includes(path.sep + 'lib' + path.sep)) delete require.cache[k];
const S = require(SRC);
const res = await S.discoverCandidates(ORIGIN, 150, cfg, { excludeLocal: true });
const cands = res.candidates || [];
console.log('候选数 =', cands.length, ' stats =', JSON.stringify(res.stats));
// 按归属分组
const groups = new Map();
for (const c of cands) {
const k = `${c.place} / ${c.placeDetail}`;
if (!groups.has(k)) groups.set(k, []);
groups.get(k).push(c.name);
}
console.log('=== 分组 ===');
for (const [k, names] of groups) console.log(' ', k, '→', names.join(', '));
// 断言:每个点的地级 + 县都要对
let fail = 0;
for (const c of cands) {
const row = REAL.find((r) => r[0] === c.name);
if (!row) { console.log(' ? 未知候选', c.name); continue; }
const [, , , , , expArea, expDetail] = row;
if (c.place !== expArea || c.placeDetail !== expDetail) {
fail += 1;
console.log(` ✗ ${c.name} → ${c.place}/${c.placeDetail || '(空)'},期望 ${expArea}/${expDetail}`);
}
}
// 空组名检查
for (const c of cands) {
if (!c.place || c.place === '?' || c.placeDetail === '?') { fail += 1; console.log(' ✗ 空组名', c.name); }
}
// 景点名当组名的检查:Photon 的 osm.place.city 类目会混进景点 POI,
// 它们的名字也带「市/州」后缀,不能拿来当组名。
for (const c of cands) {
const looksLikePlace = /(市|州|盟|地区)$/.test(c.place || '');
if (looksLikePlace && c.name !== c.place && !c.placeDetail) {
// 组名是某个地名,但这个候选自己也是地名(比如「柳州市」本身)→ 允许
if (c.name !== c.place) {
fail += 1;
console.log(` ✗ 疑似景点自成一组:${c.name} → ${c.place}`);
}
}
if (c.place && c.place === c.name && !/^(.+?)(市|州|盟|地区)$/.test(c.name)) {
fail += 1;
console.log(` ✗ 景点名被当成组名:${c.name}`);
}
}
console.log(fail ? `\n❌ ${fail} 项不符` : `\n✅ 全部 ${cands.length} 个候选归属正确`);
// 第二组:Photon 的 osm.place.city 类目会混进景点 POI(名字也带「市/州」后缀),
// 它们不能被当成「地名节点」,否则景点会自己成一组。
console.log('\n=== 城市类目里的景点 POI ===');
{
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 cs = [
{ name: '柳州之门', lat: 24.3089, lon: 109.4112, kind: '城市', osmCounty: '柳州市', osmCity: '城中区' },
{ name: '柳州市', lat: 24.3254, lon: 109.4155, kind: '城市', osmCounty: '', osmCity: '' },
];
await S2.attachPlaces(cs, {});
const ok1 = cs[0].place === '柳州市' && cs[0].placeDetail === '城中区';
const ok2 = cs[1].place === '柳州市';
if (!ok1) { fail += 1; console.log(` ✗ 景点被当成了地名节点 → ${cs[0].place}/${cs[0].placeDetail}`); }
else console.log(` ✓ 景点走正常归属 → ${cs[0].place} / ${cs[0].placeDetail}`);
if (!ok2) { fail += 1; console.log(` ✗ 真的城市没认出 → ${cs[1].place}`); }
else console.log(` ✓ 真城市仍作地名节点 → ${cs[1].place}`);
}
console.log(fail ? `\n❌ 共 ${fail} 项不符` : '\n✅ 全部通过');
server.close();
process.exit(fail ? 1 : 0);
});