Files
tripsense/tools/test-admin-lookup.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

87 lines
3.7 KiB
JavaScript

'use strict';
/**
* 归属判定离线单测(不发网络请求)。
*
* 覆盖三件事:
* 1. 同名县按坐标消歧(城中区:柳州 vs 西宁)
* 2. 已知边界点归属正确(环江喀斯特→河池市/环江县,不许跑到荔波县)
* 3. 下层地名是**县**,不是村/街道
*/
const { attachPlaces } = require('../lib/sources');
const CASES = [
// [名称, lat, lon, 期望地级, 期望下层县]
['环江喀斯特世界自然遗产-核心功能区', 25.1, 108.28, '河池市', '环江毛南族自治县'],
['大化瑶族自治县七百弄', 24.35, 107.99, '河池市', '大化瑶族自治县'],
['都安瑶族自治县', 24.09, 108.10, '河池市', '都安瑶族自治县'],
['荔波小七孔', 25.25, 107.88, '黔南州', '荔波县'],
['柳州钢铁厂旧址', 24.33, 109.42, '柳州市', '城中区'],
['西宁某点', 36.62, 101.78, '西宁市', '城中区'],
['鼓楼区南京点', 32.06, 118.77, '南京市', '鼓楼区'],
['福州鼓楼点', 26.08, 119.30, '福州市', '鼓楼区'],
['徐州鼓楼点', 34.26, 117.19, '徐州市', '鼓楼区'],
];
(async () => {
// 构造候选:带 OSM 归属字段,模拟真实场景里 attachPlaces 走的那条分支
const cands = CASES.map(([name, lat, lon]) => ({
name, lat, lon, kind: '景区',
osmCounty: '', osmCity: '', osmDistrict: '',
}));
// 第一轮:喂「OSM 自带的县名字段」
const withNames = [
['环江喀斯特世界自然遗产-核心功能区', '环江毛南族自治县'],
['大化瑶族自治县七百弄', '大化瑶族自治县'],
['都安瑶族自治县', '都安瑶族自治县'],
['荔波小七孔', '荔波县'],
['柳州钢铁厂旧址', '城中区'],
['西宁某点', '城中区'],
['鼓楼区南京点', '鼓楼区'],
['福州鼓楼点', '鼓楼区'],
['徐州鼓楼点', '鼓楼区'],
];
withNames.forEach((c, i) => { cands[i].osmCounty = c[1]; });
await attachPlaces(cands, {});
let pass = 0;
let fail = 0;
CASES.forEach(([name, , , expArea, expDetail], i) => {
const c = cands[i];
const okArea = c.place === expArea;
const okDetail = c.placeDetail === expDetail;
if (okArea && okDetail) {
pass += 1;
console.log(` ✓ ${name} → ${c.place} / ${c.placeDetail}`);
} else {
fail += 1;
console.log(` ✗ ${name} → 实际 ${c.place} / ${c.placeDetail || '(空)'},期望 ${expArea} / ${expDetail}`);
}
});
// 第二轮:不给任何 OSM 字段,纯靠坐标兜底(应落到最近县,仍不应出现空组名)
const bare = CASES.map(([name, lat, lon]) => ({ name, lat, lon, kind: '景区' }));
await attachPlaces(bare, {});
console.log(' --- 无 OSM 字段(坐标兜底)');
bare.forEach((c, i) => {
const ok = c.place && c.place !== '?' && c.placeDetail !== '?';
if (ok) pass += 1; else fail += 1;
console.log(` ${ok ? '✓' : '✗'} ${CASES[i][0]} → ${c.place || '(空)'} / ${c.placeDetail || '(空)'}`);
});
// 第三轮:狭长县的远端点。大化县中心点在西北角,东南角的响水关离中心 85km,
// 按 80km 一刀切会把**正确的**县名丢掉。精确全称命中要放宽阈值。
console.log(' --- 狭长县远端点');
{
const c = { name: '响水关', lat: 24.7217, lon: 107.6123, kind: '景区', osmCounty: '大化瑶族自治县' };
await attachPlaces([c], {});
const ok = c.place === '河池市' && c.placeDetail === '大化瑶族自治县';
if (ok) pass += 1; else fail += 1;
console.log(` ${ok ? '✓' : '✗'} 响水关 → ${c.place} / ${c.placeDetail || '(空)'}`);
}
console.log(`\n通过 ${pass} / 失败 ${fail}`);
process.exit(fail ? 1 : 0);
})();