功能:完善加氢站导航详情与缓存刷新

This commit is contained in:
lingniu
2026-09-02 14:05:49 +08:00
parent e7ee8583aa
commit a26179c8fe
5 changed files with 151 additions and 17 deletions
+25 -10
View File
@@ -52,6 +52,9 @@ function stationSearchScore(station, query) {
function toPinyin(value) { const converter = globalThis.pinyinPro?.pinyin; return converter ? normalize(converter(String(value), { toneType: 'none', separator: ' ' })) : ''; }
function initials(value) { return String(value).split(/\s+/).map(word => word[0] || '').join(''); }
function normalize(value) { return String(value || '').trim().toLocaleLowerCase(); }
function adminNameKey(value) { return String(value || '').trim().replace(/(特别行政区|壮族自治区|回族自治区|维吾尔自治区|自治区|省|市)$/, ''); }
function isMunicipalityProvince(value) { return ['北京', '上海', '天津', '重庆'].includes(adminNameKey(value)); }
function isMunicipalityCity(province, city) { return isMunicipalityProvince(province) && adminNameKey(province) === adminNameKey(city); }
function hasCoordinates(station) { return Number.isFinite(Number(station.longitude)) && Number.isFinite(Number(station.latitude)); }
function coords(station) { return [Number(station.longitude), Number(station.latitude)]; }
function stationDistrictName(station) { if (station?.district) return String(station.district).trim(); let address = String(station?.address || '').trim(); for (const prefix of [station?.province, station?.city]) { const normalized = String(prefix || '').trim(); if (normalized) address = address.split(normalized).join(''); } return address.match(/^(.{2,10}?(?:区|县|旗|市))/)?.[1] || ''; }
@@ -106,7 +109,14 @@ function renderList() {
document.getElementById('rankViewportMeta').textContent = `当前视野 ${formatNumber(visibleNodes.length)} · 全部 ${formatNumber(allNodes.length)}`;
document.getElementById('panelRankTitle').textContent = state.userLocation && level === 'station' ? `附近${state.stationFilter === 'partner' ? '合作' : ''}加氢站` : level === 'province' ? '加氢站省份 TOP 排名' : level === 'city' ? '加氢站城市 TOP 排名' : '加氢站 TOP 排名';
if (!display.length) { box.innerHTML = '<div class="rank-empty">未找到匹配站点<br>请尝试名称、地址、城市或拼音首字母</div>'; return; }
box.innerHTML = display.slice(0, 100).map((node, index) => { const distance = node.kind === 'station' ? stationDistance(node.station) : Number.POSITIVE_INFINITY, partnerTag = node.kind === 'station' ? `<span class="station-list-partner-tag${node.cooperative ? '' : ' is-placeholder'}"${node.cooperative ? '' : ' aria-hidden="true"'}>${node.cooperative ? '合作' : ''}</span>` : '', rowValue = Number.isFinite(distance) ? `${formatNumber(distance, 1)} km` : node.kind === 'station' ? '' : `${formatNumber(node.count)}`; return `<button type="button" class="rank-glass-row${node.kind === 'station' && state.selected?.id === node.station.id ? ' is-selected' : ''}" data-node-id="${escapeAttribute(node.id)}"><span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${partnerTag}<span class="station-list-name">${escapeHTML(node.name)}</span></span>${rowValue ? `<span class="r-val">${rowValue}</span>` : ''}</button>`; }).join('');
box.innerHTML = display.slice(0, 100).map((node, index) => {
const station = node.kind === 'station' ? node.station : null, distance = station ? stationDistance(station) : Number.POSITIVE_INFINITY;
const partnerTag = station ? `<span class="station-list-partner-tag${node.cooperative ? '' : ' is-placeholder'}"${node.cooperative ? '' : ' aria-hidden="true"'}>${node.cooperative ? '合作' : ''}</span>` : '';
const rowValue = Number.isFinite(distance) ? `${formatNumber(distance, 1)} km` : station ? '' : `${formatNumber(node.count)}`;
const price = station?.cooperative ? stationUnitPrice(station) : '';
const valueStack = price || rowValue ? `<span class="r-value-stack">${price ? `<span class="station-list-price">${escapeHTML(price)}</span>` : ''}${rowValue ? `<span class="r-val">${escapeHTML(rowValue)}</span>` : ''}</span>` : '';
return `<button type="button" class="rank-glass-row${station && state.selected?.id === station.id ? ' is-selected' : ''}" data-node-id="${escapeAttribute(node.id)}"><span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${partnerTag}<span class="station-list-name">${escapeHTML(node.name)}</span></span>${valueStack}</button>`;
}).join('');
box.querySelectorAll('[data-node-id]').forEach(button => button.addEventListener('click', () => { const node = display.find(item => item.id === button.dataset.nodeId); if (node?.kind === 'station') selectStation(node.station, true); else if (node) state.map?.setZoomAndCenter(nextZoomForLevel(node.level), node.lnglat); }));
}
function compareNodes(left, right) { if (state.userLocation && left.kind === 'station' && right.kind === 'station') return stationDistance(left.station) - stationDistance(right.station); return right.count - left.count || String(left.name || '').localeCompare(String(right.name || ''), 'zh-CN'); }
@@ -117,7 +127,7 @@ function fuzzySearchScore(value, query) {
const pinyin = toPinyin(value); if (pinyin === needle || pinyin.startsWith(needle) || initials(pinyin).startsWith(needle)) return 3;
return pinyin.includes(needle) || initials(pinyin).includes(needle) ? 4 : Number.POSITIVE_INFINITY;
}
function compactPath(parts) { return parts.filter(Boolean).join(' · '); }
function compactPath(parts) { return parts.filter(Boolean).filter((part, index, list) => index === 0 || adminNameKey(part) !== adminNameKey(list[index - 1])).join(' · '); }
function stationLocationOptions() {
const groups = new Map();
const add = (level, station) => {
@@ -127,12 +137,13 @@ function stationLocationOptions() {
if (!groups.has(key)) groups.set(key, { type: 'location', level, ...filter, stations: [] });
groups.get(key).stations.push(station);
};
for (const station of stationTypeScope()) { const district = stationDistrictName(station); if (station.province) add('province', station); if (station.province && station.city) add('city', station); if (station.province && station.city && district) add('district', { ...station, district }); }
for (const station of stationTypeScope()) { const district = stationDistrictName(station); if (station.province) add('province', station); if (station.province && station.city && !isMunicipalityCity(station.province, station.city)) add('city', station); if (station.province && station.city && district) add('district', { ...station, district }); }
return [...groups.values()].map(option => {
const target = option.level === 'province' ? option.province : option.level === 'city' ? option.city : option.district;
const longitude = option.stations.reduce((sum, station) => sum + Number(station.longitude), 0) / option.stations.length;
const latitude = option.stations.reduce((sum, station) => sum + Number(station.latitude), 0) / option.stations.length;
return { ...option, label: stationGroupName({ province: target, city: target }, option.level === 'province' ? 'province' : 'city'), path: compactPath([option.province, option.city, option.district]), lnglat: [longitude, latitude] };
const label = option.level === 'district' ? option.district : stationGroupName({ province: target, city: target }, option.level === 'province' ? 'province' : 'city');
return { ...option, label, path: compactPath([option.province, option.city, option.district]), lnglat: [longitude, latitude] };
});
}
function stationLocationSuggestions(query) {
@@ -180,9 +191,10 @@ function regionPickerOptions() {
const picker = state.regionPicker; if (!picker) return [];
const all = stationLocationOptions(); const needle = normalize(picker.search);
if (needle) return all.map(option => ({ ...option, score: Math.min(fuzzySearchScore(option.label, needle), fuzzySearchScore(option.path, needle)) })).filter(option => Number.isFinite(option.score)).sort((left, right) => left.score - right.score || left.label.localeCompare(right.label, 'zh-CN')).slice(0, 80);
if (picker.level === 'province') return all.filter(option => option.level === 'province');
if (picker.level === 'province') return all.filter(option => option.level === 'province').sort((left, right) => right.stations.length - left.stations.length || left.label.localeCompare(right.label, 'zh-CN'));
if (picker.level === 'city' && isMunicipalityProvince(picker.province)) return all.filter(option => option.level === 'district' && option.province === picker.province);
if (picker.level === 'city') return all.filter(option => option.level === 'city' && option.province === picker.province);
return all.filter(option => option.level === 'district' && option.province === picker.province && option.city === picker.city);
return all.filter(option => option.level === 'district' && option.province === picker.province && (!picker.city || option.city === picker.city));
}
function regionPickerOptionPath(option) {
const parts = option.level === 'province' ? [option.province] : option.level === 'city' ? [option.province, option.city] : [option.province, option.city, option.district];
@@ -213,7 +225,7 @@ function renderRegionPicker() {
}
function setRegionPickerSearch(value) { if (!state.regionPicker) return; state.regionPicker.search = value; renderRegionPicker(); }
function navigateRegionPicker(level) { if (!state.regionPicker) return; state.regionPicker.search = ''; if (level === 'province') { state.regionPicker.level = 'province'; state.regionPicker.province = ''; state.regionPicker.city = ''; } else if (level === 'city') { state.regionPicker.level = 'city'; state.regionPicker.city = ''; } else state.regionPicker.level = 'district'; renderRegionPicker(); }
function descendRegionPicker(index) { const option = state.regionPicker?.options[index]; if (!option || option.level === 'district') return; state.regionPicker.search = ''; state.regionPicker.level = option.level === 'province' ? 'city' : 'district'; state.regionPicker.province = option.province; state.regionPicker.city = option.city || ''; renderRegionPicker(); }
function descendRegionPicker(index) { const option = state.regionPicker?.options[index]; if (!option || option.level === 'district') return; state.regionPicker.search = ''; state.regionPicker.level = option.level === 'province' && isMunicipalityProvince(option.province) ? 'district' : option.level === 'province' ? 'city' : 'district'; state.regionPicker.province = option.province; state.regionPicker.city = option.level === 'province' && isMunicipalityProvince(option.province) ? '' : option.city || ''; renderRegionPicker(); }
function toggleRegionPickerOption(index) { const option = state.regionPicker?.options[index]; if (!option) return; const key = locationFilterKey(option), draft = state.regionPicker.draft; const found = draft.findIndex(filter => locationFilterKey(filter) === key); if (found >= 0) draft.splice(found, 1); else draft.push(cloneLocationFilter(option)); renderRegionPicker(); }
function removeRegionPickerDraft(index) { if (!state.regionPicker) return; state.regionPicker.draft.splice(index, 1); renderRegionPicker(); }
function clearRegionPickerDraft() { if (!state.regionPicker) return; state.regionPicker.draft = []; renderRegionPicker(); }
@@ -239,11 +251,14 @@ function selectStation(station, fit = false) {
if (!station) return; state.selected = station; if (fit) state.map?.setZoomAndCenter(14.5, coords(station));
document.getElementById('detailName').textContent = station.name || station.shortName || '未命名站点'; document.getElementById('detailRegion').textContent = stationRegion(station);
const cooperative = Boolean(station.cooperative), tag = document.getElementById('detailType'); tag.textContent = cooperative ? '合作站' : '外部站'; tag.classList.toggle('is-station', cooperative);
const distance = stationDistance(station); document.getElementById('detailFields').innerHTML = `${detailField('站点状态', cooperative ? '合作站点' : '外部站点')}${detailField('行政区域', stationRegion(station))}${detailField('详细地址', station.address || '暂无详细地址', true)}${Number.isFinite(distance) ? detailField('距离我', `${formatNumber(distance, 1)} km`) : ''}`;
const distance = stationDistance(station), partnerFields = cooperative ? `${detailField('单价', stationUnitPrice(station) || '暂无')}${detailField('联系人', station.contactPerson || '暂无')}${detailPhoneField('联系方式', station.contactPhone)}` : '';
document.getElementById('detailFields').innerHTML = `${detailField('站点状态', cooperative ? '合作站点' : '外部站点')}${partnerFields}${detailField('行政区域', stationRegion(station))}${detailField('详细地址', station.address || '暂无详细地址', true)}${Number.isFinite(distance) ? detailField('距离我', `${formatNumber(distance, 1)} km`) : ''}`;
document.getElementById('stationDetail').hidden = false; renderMarkers(); renderList();
}
function detailField(label, value, full = false) { return `<div class="detail-field${full ? ' is-full' : ''}"><dt>${escapeHTML(label)}</dt><dd>${escapeHTML(value)}</dd></div>`; }
function stationRegion(station) { return [station.province, station.city, stationDistrictName(station)].filter(value => value && value !== '[]').filter((value, index, list) => index === 0 || value !== list[index - 1]).join(' · '); }
function detailPhoneField(label, phone) { const value = String(phone || '').trim(), href = value.replace(/[^\d+]/g, ''); return `<div class="detail-field"><dt>${escapeHTML(label)}</dt><dd>${href ? `<a class="detail-phone-link" href="tel:${escapeAttribute(href)}">${escapeHTML(value)}</a>` : '暂无'}</dd></div>`; }
function stationUnitPrice(station) { const value = Number(station?.unitPrice); return Number.isFinite(value) && value > 0 ? `¥${formatNumber(value, 2)} / kg` : ''; }
function stationRegion(station) { return compactPath([station.province, station.city, stationDistrictName(station)].filter(value => value && value !== '[]')); }
function closeDetail() { const detail = document.getElementById('stationDetail'); if (detail) detail.hidden = true; }
function locateMe() { if (!navigator.geolocation) return locationFailed('当前设备不支持定位'); const button = document.getElementById('locateDeviceBtn'); button.disabled = true; button.classList.remove('is-error'); document.getElementById('locateDeviceLabel').textContent = '定位中'; navigator.geolocation.getCurrentPosition(position => { state.userLocation = wgs84ToGcj02(Number(position.coords.longitude), Number(position.coords.latitude)); button.disabled = false; button.classList.add('is-active'); document.getElementById('locateDeviceLabel').textContent = '已定位'; state.map?.setZoomAndCenter(13, state.userLocation); document.getElementById('selectedRegionHint').textContent = '视角: 我的位置附近'; render(); }, error => locationFailed(error?.code === 1 ? '请允许位置权限' : '定位失败'), { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }); }
@@ -254,7 +269,7 @@ function distanceKm(origin, target) { const radians = value => value * Math.PI /
function resetMap() { state.map?.setZoomAndCenter(4.8, [108.948024, 34.263161]); state.map?.setPitch(30); document.getElementById('selectedRegionHint').textContent = '视角: 全国加氢站网络'; }
function toggleMapPitch() { state.pitch = !state.pitch; state.map?.setPitch(state.pitch ? 30 : 0); }
function setTheme(theme) { document.body.className = theme; document.querySelectorAll('.bw-btn').forEach(button => button.classList.remove('active')); document.querySelector(theme === 'theme-white' ? '.theme-white-btn' : '.theme-dark-btn')?.classList.add('active'); state.map?.setMapStyle(theme === 'theme-white' ? 'amap://styles/light' : 'amap://styles/darkblue'); }
function navigateToStation() { if (!state.selected) return; const [lng, lat] = coords(state.selected), name = encodeURIComponent(state.selected.name || state.selected.shortName || '加氢站'); const overlay = document.getElementById('navigationOverlay'); overlay.hidden = false; window.setTimeout(() => window.location.assign(`https://uri.amap.com/navigation?to=${lng.toFixed(6)},${lat.toFixed(6)},${name}&mode=car&coordinate=gaode&callnative=1`), 140); }
function navigateToStation() { if (!state.selected) return; const [lng, lat] = coords(state.selected), name = encodeURIComponent(state.selected.name || state.selected.shortName || '加氢站'), target = window.open('', '_blank'); const overlay = document.getElementById('navigationOverlay'); overlay.hidden = false; window.setTimeout(() => { const url = `https://uri.amap.com/navigation?to=${lng.toFixed(6)},${lat.toFixed(6)},${name}&mode=car&coordinate=gaode&callnative=1`; if (target) { target.opener = null; target.location.href = url; } else window.open(url, '_blank', 'noopener'); }, 140); }
function setStatus(text, error = false) { const status = document.getElementById('mapStatusText'); if (status) status.textContent = text; status?.parentElement?.classList.toggle('is-error', error); }
function setHTML(id, value) { const element = document.getElementById(id); if (element) element.innerHTML = value; }
function formatNumber(value, digits = 0) { return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: digits, minimumFractionDigits: digits }); }
+48 -6
View File
@@ -31,6 +31,8 @@ STATION_CACHE_SECONDS = int(os.getenv("STATION_NAVIGATION_CACHE_SECONDS", "120")
VEHICLE_MAP_INTERNAL_BASE_URL = os.getenv("VEHICLE_MAP_INTERNAL_BASE_URL", "http://127.0.0.1:20800").rstrip("/")
_cache_lock = threading.Lock()
_cache = {}
_cache_load_locks = {}
_cache_refreshing = set()
def _static_asset_version():
@@ -80,16 +82,51 @@ def _post_open_platform(path, body):
return payload.get("data") or []
def _refresh_cached_value(key, loader):
try:
value = loader()
except Exception as exc:
print("{} cache background refresh deferred: {}".format(key, exc))
else:
with _cache_lock:
_cache[key] = (time.time(), value)
finally:
with _cache_lock:
_cache_refreshing.discard(key)
def _start_cache_refresh(key, loader):
threading.Thread(target=_refresh_cached_value, args=(key, loader), daemon=True).start()
def _cached(key, ttl_seconds, loader):
now = time.time()
refresh_in_background = False
with _cache_lock:
cached = _cache.get(key)
if cached and now - cached[0] < ttl_seconds:
return cached[1]
value = loader()
with _cache_lock:
_cache[key] = (now, value)
return value
if cached:
if key not in _cache_refreshing:
_cache_refreshing.add(key)
refresh_in_background = True
stale_value = cached[1]
else:
stale_value = None
load_lock = _cache_load_locks.setdefault(key, threading.Lock())
if cached:
if refresh_in_background:
_start_cache_refresh(key, loader)
return stale_value
with load_lock:
with _cache_lock:
cached = _cache.get(key)
if cached:
return _cached(key, ttl_seconds, loader)
value = loader()
with _cache_lock:
_cache[key] = (time.time(), value)
return value
def _load_station_directory():
@@ -137,7 +174,7 @@ class StationNavigationHandler(SimpleHTTPRequestHandler):
return
if path == "/api/stations":
try:
self._write_json(200, _cached("station-directory", STATION_CACHE_SECONDS, _load_station_directory))
payload = _cached("station-directory", STATION_CACHE_SECONDS, _load_station_directory)
except Exception as exc:
self.log_error("station directory refresh failed: %s", exc)
self._write_json(502, {
@@ -145,6 +182,8 @@ class StationNavigationHandler(SimpleHTTPRequestHandler):
"code": "UPSTREAM_UNAVAILABLE",
"message": "加氢站数据暂时不可用,请稍后重试",
})
return
self._write_json(200, payload)
return
if path == "/assets/logo_light.svg":
self._serve_logo()
@@ -213,7 +252,10 @@ class StationNavigationHandler(SimpleHTTPRequestHandler):
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
self.log_message("client disconnected before JSON response completed")
if __name__ == "__main__":
+5
View File
@@ -66,6 +66,9 @@
.station-navigation-shell .station-list-name { min-width: 0; overflow-wrap: anywhere; white-space: normal; }
.station-navigation-shell .station-list-partner-tag { flex: 0 0 30px; min-width: 30px; margin-top: 1px; padding: 2px 5px; border-radius: 4px; background: rgba(0,113,67,.1); color: var(--accent-primary); font-size: 9px; font-weight: 700; line-height: 1; text-align: center; }
.station-navigation-shell .station-list-partner-tag.is-placeholder { visibility: hidden; }
.station-navigation-shell .r-value-stack { display: flex; flex: 0 0 auto; align-items: flex-end; gap: 4px; margin-left: auto; }
.station-navigation-shell .station-list-price { color: var(--accent-primary); font: 700 9px/1 'JetBrains Mono', monospace; letter-spacing: -.03em; white-space: nowrap; }
.station-navigation-shell .detail-phone-link { color: var(--accent-primary); font: inherit; font-weight: 700; text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--accent-primary) 35%, transparent); text-underline-offset: 2px; }
.station-navigation-shell .rank-empty { display: grid; min-height: 160px; place-items: center; color: var(--text-muted); font-size: 12px; text-align: center; }
.station-navigation-shell .map-detail-card .detail-grid { margin-bottom: 0; }
@media (max-width: 760px) {
@@ -209,6 +212,8 @@
.station-navigation-shell .status-glass-grid3 { gap: 5px; margin-bottom: 7px; }
.station-navigation-shell .glass-cell { padding: 6px; }
.station-navigation-shell .rank-glass-row { min-height: 42px; padding: 7px 8px; }
.station-navigation-shell .r-value-stack { flex-direction: column; align-items: flex-end; gap: 3px; }
.station-navigation-shell .station-list-price { font-size: 8px; }
}
@media (max-width: 390px) {
+48 -1
View File
@@ -63,6 +63,30 @@ vm.runInNewContext(`
assert.equal(sandbox.regionProvinceLabels, '广东,浙江');
assert.equal(sandbox.regionCityLabels, '佛山,广州');
assert.match(sandbox.regionSearchLabels, /嘉兴/);
vm.runInNewContext(`
state.stations = [
{ id: 'sh-hp', name: '上海黄浦站', province: '上海市', city: '上海市', district: '黄浦区', longitude: 121.5, latitude: 31.2, cooperative: true },
{ id: 'sh-pd', name: '上海浦东站', province: '上海市', city: '上海市', district: '浦东新区', longitude: 121.6, latitude: 31.2, cooperative: true },
{ id: 'gd-gz', name: '广州站', province: '广东省', city: '广州市', district: '天河区', longitude: 113.3, latitude: 23.1, cooperative: true },
{ id: 'zj-hz-1', name: '杭州站一', province: '浙江省', city: '杭州市', district: '西湖区', longitude: 120.1, latitude: 30.2, cooperative: true },
{ id: 'zj-hz-2', name: '杭州站二', province: '浙江省', city: '杭州市', district: '上城区', longitude: 120.2, latitude: 30.2, cooperative: true },
{ id: 'zj-hz-3', name: '杭州站三', province: '浙江省', city: '杭州市', district: '拱墅区', longitude: 120.3, latitude: 30.2, cooperative: true }
];
globalThis.shanghaiCityOption = stationLocationOptions().find(option => option.level === 'city' && option.province === '上海市');
state.regionPicker = { level: 'province', province: '', city: '', search: '', draft: [], options: [] };
globalThis.primaryRegionLabels = regionPickerOptions().map(option => option.label).join(',');
state.regionPicker = { level: 'city', province: '上海市', city: '', search: '', draft: [], options: [] };
globalThis.shanghaiDistrictLabels = regionPickerOptions().map(option => option.label).sort().join(',');
globalThis.shanghaiPath = compactPath(['上海市', '上海市', '黄浦区']);
globalThis.shanghaiRegion = stationRegion(state.stations[0]);
globalThis.directMunicipalityFlags = ['北京市', '上海市', '天津市', '重庆市'].map(isMunicipalityProvince).join(',');
`, sandbox);
assert.equal(sandbox.shanghaiCityOption, undefined);
assert.equal(sandbox.primaryRegionLabels, '浙江,上海,广东');
assert.equal(sandbox.shanghaiDistrictLabels, '浦东新区,黄浦区');
assert.equal(sandbox.shanghaiPath, '上海市 · 黄浦区');
assert.equal(sandbox.shanghaiRegion, '上海市 · 黄浦区');
assert.equal(sandbox.directMunicipalityFlags, 'true,true,true,true');
vm.runInNewContext(`
globalThis.inferredDistrict = stationDistrictName({ province: '浙江省', city: '嘉兴市', address: '嘉兴市海盐县海盐经济开发区' });
state.locationFilters = [];
@@ -87,6 +111,26 @@ vm.runInNewContext(`
`, sandbox);
assert.equal(sandbox.multiLocationMatches.join(','), 'true,true,false');
assert.ok(Math.abs(sandbox.distanceKm([113, 23], [114, 23]) - 102.4) < 1);
assert.equal(sandbox.stationUnitPrice({ unitPrice: 12.5 }), '¥12.50 / kg');
assert.equal(sandbox.stationUnitPrice({ unitPrice: 0 }), '');
assert.match(sandbox.detailPhoneField('联系方式', '138 0000 0000'), /href="tel:13800000000"/);
vm.runInNewContext(`
const detailNodes = {
detailName: { textContent: '' }, detailRegion: { textContent: '' },
detailType: { textContent: '', classList: { toggle() {} } },
detailFields: { innerHTML: '' }, stationDetail: { hidden: true }
};
document.getElementById = id => detailNodes[id] || null;
state.map = null; state.selected = null;
selectStation({ id: 'partner-detail', name: '合作站', cooperative: true, province: '广东省', city: '广州市', district: '黄埔区', longitude: 113.3, latitude: 23.1, contactPerson: '张工', contactPhone: '138 0000 0000', unitPrice: 12.5 });
globalThis.partnerDetailHtml = detailNodes.detailFields.innerHTML;
selectStation({ id: 'external-detail', name: '外部站', cooperative: false, province: '广东省', city: '广州市', district: '黄埔区', longitude: 113.4, latitude: 23.1, contactPerson: '李工', contactPhone: '13900000000', unitPrice: 10.5 });
globalThis.externalDetailHtml = detailNodes.detailFields.innerHTML;
`, sandbox);
assert.match(sandbox.partnerDetailHtml, /张工/);
assert.match(sandbox.partnerDetailHtml, /tel:13800000000/);
assert.match(sandbox.partnerDetailHtml, /¥12\.50 \/ kg/);
assert.doesNotMatch(sandbox.externalDetailHtml, /李工|13900000000|¥10\.50/);
assert.match(source, /zoom < 11\) return 'city'/);
assert.match(source, /state\.userLocation && left\.kind === 'station'/);
assert.doesNotMatch(source, /rankSecondaryTab|setRank\(/);
@@ -106,12 +150,15 @@ assert.match(source, /focusSearchResults\(\)/);
assert.match(source, /visibleOnly && !normalize\(state\.query\)/);
assert.match(source, /station-list-partner-tag/);
assert.match(source, /station-list-partner-tag\$\{node\.cooperative \? '' : ' is-placeholder'\}/);
assert.match(source, /node\.kind === 'station' \? '' : `\$\{formatNumber\(node\.count\)\} 座`/);
assert.match(source, /const rowValue = Number\.isFinite\(distance\)/);
assert.match(source, /node\.cooperative \? 'H₂ · 合作' : ''/);
assert.doesNotMatch(source, /在线 \$\{formatNumber\(node\.online\)\}/);
assert.doesNotMatch(source, /monthlyHydrogenKg|totalHydrogenKg|vehicle/);
assert.match(source, /uri\.amap\.com\/navigation/);
assert.match(source, /window\.open\('', '_blank'\)/);
assert.doesNotMatch(source, /window\.location\.assign/);
assert.equal((source.match(/function navigateToStation\(/g) || []).length, 1);
assert.match(source, /station-list-price/);
assert.match(styles, /station-summary-strip/);
assert.match(styles, /header-kpi-group,\n \.station-navigation-shell \.station-status-card/);
assert.match(styles, /theme-switcher-bw \.bw-btn span \{ display: none; \}/);
+25
View File
@@ -2,6 +2,8 @@ import importlib.util
import io
import json
from pathlib import Path
import threading
import time
import unittest
from unittest.mock import patch
@@ -49,6 +51,29 @@ class StationNavigationTest(unittest.TestCase):
self.assertEqual(calls, ["load"])
self.assertEqual(first, second)
def test_expired_directory_returns_stale_value_while_refreshing_in_background(self):
started = threading.Event()
release = threading.Event()
def loader():
started.set()
release.wait(1)
return {"version": 2}
cache = {"station-directory": (time.time() - 121, {"version": 1})}
with patch.object(server, "_cache", cache), \
patch.object(server, "_cache_refreshing", set()), \
patch.object(server, "_cache_load_locks", {}):
result = server._cached("station-directory", 120, loader)
self.assertEqual(result, {"version": 1})
self.assertTrue(started.wait(1))
release.set()
for _ in range(100):
if cache["station-directory"][1] == {"version": 2}:
break
time.sleep(0.01)
self.assertEqual(cache["station-directory"][1], {"version": 2})
if __name__ == "__main__":
unittest.main()