diff --git a/station-navigation/app.js b/station-navigation/app.js index c0d9aacb..52933bcc 100644 --- a/station-navigation/app.js +++ b/station-navigation/app.js @@ -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 = '
未找到匹配站点
请尝试名称、地址、城市或拼音首字母
'; 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' ? `` : '', rowValue = Number.isFinite(distance) ? `${formatNumber(distance, 1)} km` : node.kind === 'station' ? '' : `${formatNumber(node.count)} 座`; return ``; }).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 ? `` : ''; + const rowValue = Number.isFinite(distance) ? `${formatNumber(distance, 1)} km` : station ? '' : `${formatNumber(node.count)} 座`; + const price = station?.cooperative ? stationUnitPrice(station) : ''; + const valueStack = price || rowValue ? `${price ? `${escapeHTML(price)}` : ''}${rowValue ? `${escapeHTML(rowValue)}` : ''}` : ''; + return ``; + }).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 `
${escapeHTML(label)}
${escapeHTML(value)}
`; } -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 `
${escapeHTML(label)}
${href ? `${escapeHTML(value)}` : '暂无'}
`; } +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 }); } diff --git a/station-navigation/server.py b/station-navigation/server.py index db96862a..a44b159f 100644 --- a/station-navigation/server.py +++ b/station-navigation/server.py @@ -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__": diff --git a/station-navigation/styles.css b/station-navigation/styles.css index c4213835..587d62d4 100644 --- a/station-navigation/styles.css +++ b/station-navigation/styles.css @@ -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) { diff --git a/station-navigation/tests/test_app.mjs b/station-navigation/tests/test_app.mjs index ca825082..2e1ca715 100644 --- a/station-navigation/tests/test_app.mjs +++ b/station-navigation/tests/test_app.mjs @@ -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; \}/); diff --git a/station-navigation/tests/test_server.py b/station-navigation/tests/test_server.py index 22385fab..77a86c26 100644 --- a/station-navigation/tests/test_server.py +++ b/station-navigation/tests/test_server.py @@ -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()