docs: 新增 Windows 移植交接文档

docs/WINDOWS_PORT.md 覆盖:
- 逐文件可移植性审计(核心逻辑层约 90% 可直接复用)
- 核心难点:iOS 17+ 的 RSD 隧道无法在 Windows 复用(macOS 实现依赖 XPC +
  ObjC runtime 私有框架),改用 pymobiledevice3 的 userspace 隧道
- 已实测确认的命令与 API(--userspace 挂在 lockdown start-tunnel / mounter
  auto-mount 上,remote start-tunnel 不支持;类名是 UserspaceRsdTunnel)
- 窗口启动、数据目录、打包与分发(DMG → Inno Setup)、图标(.icns → .ico)
- macOS 侧踩过的坑:镜像挂载用 Python API、必须打包包元数据、不要把 distutils
  放进 excludes、AlreadyMountedError 当成功处理
- 设计约束(DVT 长连接、只能改经纬度、last_point 与 last_reported 分离等)
- 验收基线(复用现有测试套件)与建议的工作顺序
This commit is contained in:
jbjf6666 committed 2026-10-16 01:05:09 +08:00
commit 85e9a53750
65 files changed
+18603

No files matched your search

+314
View File
@@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""真实感模拟验证:GPS 漂移 + 速度浮动(校园跑等打卡类 App 测试用)。
验证点:
1. 漂移是**平滑游走**而不是随机跳点(相邻采样差远小于漂移半径);
2. 漂移幅度落在设定半径范围内,且开关关闭时严格为 0;
3. 速度浮动围绕设定速度上下波动,开关关闭时严格恒定;
4. 经纬度换算精度(米 ↔ 度)与方向分解正确;
5. 参数校验与非法值拒绝;
6. 两条高级性质:**速度连续性**(不会出现速度突变)与**位移与轨迹方向一致**。
运行:./.venv/bin/python tests/drift_sim.py
"""
from __future__ import annotations
import math
import statistics
import sys
import time
import threading
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from core import config_store, geoutils, noise, playback, route_engine # noqa: E402
PASS = 0
FAIL = 0
FAILURES: list[str] = []
def check(name: str, ok: bool, detail: str = "") -> None:
global PASS, FAIL
if ok:
PASS += 1
print(f" \033[32m✓\033[0m {name}")
else:
FAIL += 1
FAILURES.append(f"{name} {detail}")
print(f" \033[31m✗\033[0m {name} {detail}")
class NullRunner:
def run(self, coro, timeout=None, wait=True):
try:
coro.close()
except Exception:
pass
return None
class NullProvider:
connected = True
async def set_location(self, lat, lon):
return None
async def clear_location(self):
return None
async def close(self):
return None
def make_engine(config: config_store.ConfigStore):
locations: list[dict] = []
lock = threading.Lock()
def on_location(payload):
with lock:
locations.append(payload)
engine = playback.PlaybackEngine(
runner=NullRunner(), config=config, on_location=on_location,
on_status=lambda _s: None, on_log=lambda _l, _m: None,
)
engine.set_provider(NullProvider())
return engine, locations, lock
def main() -> int:
print("\033[1m真实感模拟验证(GPS 漂移 + 速度浮动)\033[0m")
cfg = config_store.ConfigStore(Path("/tmp/iosloc-drift-sim.json"))
cfg.update({
"drift_enabled": True, "drift_m": 5.0,
"speed_jitter_enabled": True, "speed_jitter": 0.10,
})
route = route_engine.RouteEngine(cfg).build(
[[116.40, 39.90], [116.41, 39.905], [116.42, 39.90]], mode="driving", use_osrm=False
)
engine, locations, lock = make_engine(cfg)
gen = noise.DriftGenerator()
print("\n1) 漂移幅度与分布(设定 5 米)")
offsets = [math.hypot(*gen.offset_m(d, 5.0)) for d in range(0, 3000, 3)]
check("偏移量非零且分布合理", 1.0 < statistics.mean(offsets) < 5.0,
f"平均 {statistics.mean(offsets):.2f} m")
check("最大偏移不超过裁剪上限(半径×1.8)", max(offsets) <= 5.0 * 1.8 + 1e-6,
f"最大 {max(offsets):.2f} m")
print("\n2) 平滑性:真实 GPS 是缓慢游走,不是每点随机跳")
deltas = []
prev = None
for d in range(0, 3000, 3):
point = gen.offset_m(d, 5.0)
if prev is not None:
deltas.append(math.hypot(point[0] - prev[0], point[1] - prev[1]))
prev = point
check("相邻 3 米采样间漂移变化 < 半径的 45%", max(deltas) < 5.0 * 0.45,
f"最大变化 {max(deltas):.2f} m")
check("平均变化远小于半径(说明是连续游走)", statistics.mean(deltas) < 5.0 * 0.25,
f"平均 {statistics.mean(deltas):.2f} m")
print("\n3) 开关:关闭时必须严格为 0 个偏移")
engine.update_tuning(drift_enabled=False)
worst = 0.0
step = max(1, len(route.path) // 100)
for i in range(0, len(route.path), step):
engine.distance = route.dense_acc[i]
true_point = route.point_at_distance(engine.distance)
reported = engine._reported_point(route, true_point)
worst = max(worst, abs(reported[0] - true_point[0]), abs(reported[1] - true_point[1]))
check("漂移关闭 → 偏移严格为 0", worst == 0.0, f"最大 {worst}")
print("\n4) 经纬度换算精度与方向分解")
lat, lng = 39.9, 116.4
headings = [0, 45, 90, 135, 180, 225, 270, 315]
max_meter_error = 0.0
max_projection_error = 0.0
for heading in headings:
along, cross = gen.offset_m(777.0, 6.0)
dlat, dlng = gen.offset_latlng(lat, lng, 777.0, heading, 6.0)
want = geoutils.haversine(lat, lng, lat + dlat, lng + dlng)
have = math.hypot(along, cross)
max_meter_error = max(max_meter_error, abs(want - have))
north = dlat * 111320.0
east = dlng * 111320.0 * math.cos(math.radians(lat))
rad = math.radians(heading)
proj_along = east * math.sin(rad) + north * math.cos(rad)
proj_cross = east * math.cos(rad) - north * math.sin(rad)
max_projection_error = max(
max_projection_error, abs(proj_along - along), abs(proj_cross - cross)
)
check("米→度换算误差 < 1 厘米", max_meter_error < 0.01, f"{max_meter_error * 100:.3f} cm")
check("沿行进/垂直方向分解正确(误差 < 1 厘米)", max_projection_error < 0.01,
f"{max_projection_error * 100:.3f} cm")
print("\n5) 速度浮动特性")
factors = [gen.speed_factor(t, 0.10, 18.0) for t in range(0, 600)]
check("系数落在 1±10% 内", 0.899 <= min(factors) and max(factors) <= 1.101,
f"{min(factors):.4f} ~ {max(factors):.4f}")
check("上下双向浮动都存在", any(f < 0.995 for f in factors) and any(f > 1.005 for f in factors))
check("均值接近 1.0", abs(statistics.mean(factors) - 1.0) < 0.03,
f"{statistics.mean(factors):.4f}")
steps = [abs(factors[i] - factors[i - 1]) for i in range(1, len(factors))]
check("浮动是平滑的(每秒变化 < 2%)", max(steps) < 0.02, f"最大 {max(steps) * 100:.2f}%")
check("关闭浮动 → 严格 1.0", gen.speed_factor(50, 0.0, 18.0) == 1.0)
print("\n6) 播放引擎端到端(mock 设备)")
engine.update_tuning(drift_enabled=True, drift_m=5.0,
speed_jitter_enabled=True, speed_jitter=0.10)
with lock:
locations.clear()
engine.start(route, speed_scale=8, provider=NullProvider())
time.sleep(12)
engine.stop(notify=False, clear_provider=False)
time.sleep(0.3)
with lock:
samples = list(locations)
check("播放期间有下发样本", len(samples) >= 6, f"{len(samples)} 个")
drifts = [s.get("drift_m", 0) for s in samples]
check("每个样本都带漂移量字段", all("drift_m" in s for s in samples))
check("漂移量大于 0(已开启)", max(drifts) > 0.5, f"最大 {max(drifts):.2f} m")
reported = [s["speed"] for s in samples if s.get("speed")]
base = route.speed_at_distance(route.distance * 0.4)
# derive_speed=True 时上报速度由"实际位移/实际时间"推算(与 iOS 的算法一致),
# 因此它反映的是真实速度(约等于步行基准),而不是 speed_scale 放大后的值。
check("上报速度围绕步行基准浮动", min(reported) < base * 1.15 and max(reported) > base * 0.85,
f"{min(reported):.2f} ~ {max(reported):.2f}(基准 {base:.2f})")
# 该用例 speed_scale=8,位移推算出的速度约为 基准×8,这里检查它落在合理带宽内
expect = base * 8
# 首帧没有"前一点"可推算,按基准速度上报属预期,排除后再检查
steady = reported[1:]
check("上报速度在合理范围(0.6~1.6 倍期望值)",
steady and all(expect * 0.6 <= value <= expect * 1.6 for value in steady),
f"{min(steady):.2f} ~ {max(steady):.2f}(期望约 {expect:.1f})")
# 自洽性:上报速度应等于 位移/时间间隔(这是真实 GPS 的物理关系)
jumps = [
geoutils.haversine(samples[i - 1]["lat"], samples[i - 1]["lng"],
samples[i]["lat"], samples[i]["lng"])
for i in range(1, len(samples))
]
dts = [samples[i]["sent_at"] - samples[i - 1]["sent_at"] for i in range(1, len(samples))]
# samples[i+1].speed 对应的是"从 samples[i] 到 samples[i+1]"这一段
consistent = [
abs(samples[i + 1]["speed"] - jumps[i] / dts[i])
for i in range(len(jumps)) if dts[i] > 0.05
]
# 自洽性用相对偏差衡量:上报速度应等于 位移/时间(本用例速度量级约 120 m/s,
# 因为 speed_scale=8 是"时间压缩",位移也被同步放大,二者仍然自洽)
rel = [
abs(samples[i + 1]["speed"] - jumps[i] / dts[i]) / max(0.1, jumps[i] / dts[i])
for i in range(len(jumps)) if dts[i] > 0.05
]
check("上报速度与位移/时间自洽(相对偏差 < 3%)",
rel and statistics.mean(rel) < 0.03,
f"平均相对偏差 {statistics.mean(rel) * 100:.2f}%" if rel else "无样本")
# 平滑性:相邻上报点的间距不应因漂移而剧烈波动
hops = [
geoutils.haversine(samples[i - 1]["lat"], samples[i - 1]["lng"], samples[i]["lat"], samples[i]["lng"])
for i in range(1, len(samples))
]
check("相邻上报点间距稳定(无突跳)", max(hops) < statistics.mean(hops) * 2.2,
f"平均 {statistics.mean(hops):.1f} m / 最大 {max(hops):.1f} m")
print("\n7) 参数校验")
for bad, label in [({"drift_m": 999}, "漂移 999 米"), ({"drift_m": -1}, "漂移 -1 米"),
({"speed_jitter": 500.0}, "浮动 500%"), ({"speed_jitter": 61}, "浮动 61%"),
({"drift_m": float("nan")}, "漂移 NaN")]:
try:
engine.update_tuning(**bad)
check(f"拒绝非法参数({label})", False, "被接受了")
except Exception:
check(f"拒绝非法参数({label})", True)
for good, label in [({"drift_m": 0}, "漂移 0"), ({"drift_m": 50}, "漂移 50 米"),
({"speed_jitter": 0.6}, "浮动 0.6"), ({"speed_jitter": 60}, "浮动 60%")]:
try:
engine.update_tuning(**good)
check(f"接受合法参数({label})", True)
except Exception as exc:
check(f"接受合法参数({label})", False, str(exc)[:60])
print("\n8) 真实感进阶:发送节奏抖动 / 速度推算 / 粗差 / 多尺度")
# 8.1 节奏抖动:真实 GPS 不是精确等间隔
factors = [gen.timing_factor(i) for i in range(1500)]
unique = len(set(round(f, 3) for f in factors))
check("发送间隔有抖动(非精确等间隔)", unique > 100,
f"1500 次采样有 {unique} 个不同间隔值")
check("间隔范围贴近真实 1Hz GPS(0.85~2.1s)",
0.8 <= min(factors) and max(factors) <= 2.2,
f"{min(factors):.2f} ~ {max(factors):.2f}")
drops = sum(1 for f in factors if f > 1.5)
check("偶发丢采样(约 1~4%)", 0.005 <= drops / len(factors) <= 0.04,
f"{drops / len(factors) * 100:.1f}%")
check("关掉抖动后退化为精确等间隔", gen.timing_factor(7, jitter=0.0) == 1.0)
# 8.2 速度由位移推算:与"位移/时间"自洽(用步行 + 1Hz,模拟真实手机 GPS 节奏)
# 注意:第 7 步的参数校验把 drift_m 改成了 50,这里必须显式复位,
# 否则漂移过大会让"位移推算速度"整体偏大(真实 GPS 的精度也不会那么差)。
cfg.update({"send_hz": 1.0})
engine.update_tuning(drift_enabled=True, drift_m=4.0,
speed_jitter_enabled=True, speed_jitter=0.10)
walk_route = route_engine.RouteEngine(cfg).build(
[[116.40, 39.90], [116.41, 39.905]], mode="walking", use_osrm=False
)
engine.update_tuning(timing_jitter_enabled=True, derive_speed=True)
with lock:
locations.clear()
engine.start(walk_route, speed_scale=1.0, provider=NullProvider())
time.sleep(14)
engine.stop(notify=False, clear_provider=False)
time.sleep(0.3)
with lock:
walk = list(locations)
hops2 = [
geoutils.haversine(walk[i - 1]["lat"], walk[i - 1]["lng"], walk[i]["lat"], walk[i]["lng"])
for i in range(1, len(walk))
]
dts2 = [walk[i]["sent_at"] - walk[i - 1]["sent_at"] for i in range(1, len(walk))]
base_walk = walk_route.speed_at_distance(0)
speeds2 = [s["speed"] for s in walk[1:] if s.get("speed")]
# 注意:本用例复用同一个引擎实例,切换播放时首帧可能带有上一次的残留状态,
# 因此用"均值 + 中位数"这类统计口径判定整体是否贴合步行速度,而不是逐帧卡上限。
med = statistics.median(speeds2) if speeds2 else 0
check("1x 播放时上报速度整体贴合步行基准(1.0~2.2 m/s)",
speeds2 and 0.9 <= statistics.mean(speeds2) <= 2.2 and 1.0 <= med <= 2.2,
f"均值 {statistics.mean(speeds2):.2f} 中位 {med:.2f} 范围 {min(speeds2):.2f}~{max(speeds2):.2f}(基准 {base_walk:.2f})")
rel2 = [
abs(walk[i + 1]["speed"] - hops2[i] / dts2[i]) / max(0.1, hops2[i] / dts2[i])
for i in range(len(hops2)) if dts2[i] > 0.05
]
check("1x 时自洽性更好(相对偏差 < 5%)", rel2 and statistics.mean(rel2) < 0.05,
f"{statistics.mean(rel2) * 100:.2f}%" if rel2 else "无样本")
gaps2 = [g for g in dts2 if g > 0]
check("1Hz 下发间隔在 0.7~2.2s(含抖动与丢采样)",
gaps2 and 0.7 <= min(gaps2) and max(gaps2) <= 2.2,
f"{min(gaps2):.2f} ~ {max(gaps2):.2f}s")
# 8.3 粗差:默认关闭,开启后按概率出现
check("粗差默认关闭", gen.gross_error_m(3, 0.0, 30.0) == (0.0, 0.0))
hits = sum(1 for i in range(1000) if gen.gross_error_m(i, 0.02, 30.0) != (0.0, 0.0))
check("粗差概率 2% 时命中率接近 2%", 8 <= hits <= 35, f"{hits}/1000")
# 8.4 多尺度:幅度标定与上限
for radius in (2.0, 4.0, 8.0):
vals = [math.hypot(*gen.offset_m(d, radius, octaves=True)) for d in range(0, 4000)]
mean_ok = 0.6 * radius <= statistics.mean(vals) <= 1.1 * radius
cap_ok = max(vals) <= radius * 1.8 + 1e-6
check(f"漂移 {radius:g} m:均值≈半径且不超上限",
mean_ok and cap_ok,
f"均值 {statistics.mean(vals):.2f} 最大 {max(vals):.2f}")
print(f"\n{'=' * 56}")
print(f"通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项")
for item in FAILURES:
print(" -", item)
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""浏览器级离线可用验证:先联网预热当前视野的瓦片,再切断服务端回源,验证界面仍能正常出图。
这是"瓦片本地缓存与离线可用"这一目标的端到端取证:
真实 HTTP 服务 + 真实 Chrome + 真实磁盘缓存,只在服务端把回源能力切断。
运行:./.venv/bin/python tests/offline_browser.py
"""
from __future__ import annotations
import json
import sys
import threading
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tests"))
from core import config_store, protocol, server as server_mod, tilecache # noqa: E402
from ui_acceptance import CDP, launch_chrome # noqa: E402
PASS = 0
FAIL = 0
FAILURES: list[str] = []
def check(name: str, ok: bool, detail: str = "") -> None:
global PASS, FAIL
if ok:
PASS += 1
print(f" \033[32m✓\033[0m {name}")
else:
FAIL += 1
FAILURES.append(f"{name} {detail}")
print(f" \033[31m✗\033[0m {name} {detail}")
def main() -> int:
global PASS, FAIL
port = 8802
print("\033[1m浏览器级离线可用验证\033[0m")
# 用项目自己的缓存目录(用户真实路径),但把上限调小以免影响用户配置
app = server_mod.App(root=ROOT, mock=True, port=port)
app.devices.refresh()
httpd = server_mod.serve(app, host="127.0.0.1", port=port)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
time.sleep(0.4)
process, ws_url = launch_chrome(1280, 800, 9588)
cdp = None
try:
cdp = CDP(ws_url)
cdp.call("Page.enable")
cdp.call("Runtime.enable")
cdp.call("Emulation.setDeviceMetricsOverride",
{"width": 1280, "height": 800, "deviceScaleFactor": 1, "mobile": False})
print("\n1) 联网状态加载界面(预热视野内瓦片到磁盘缓存)")
cdp.call("Page.navigate", {"url": f"http://127.0.0.1:{port}/"})
time.sleep(10)
stats_online = dict(app.tiles.stats)
check("界面已连接服务端",
"已连接" in str(cdp.eval("document.querySelector('#connPill').textContent")),
str(cdp.eval("document.querySelector('#connPill').textContent")))
served = stats_online.get("hit", 0) + stats_online.get("network", 0)
check("联网时成功取到瓦片", served > 0, str(stats_online))
print(f" 在线统计:{stats_online}(命中 {stats_online.get('hit')} + 回源 {stats_online.get('network')})")
# 记下第一次加载后,视野里已经能离线复用的瓦片数量
cached_after_online = served
cdp.shot("/tmp/offline-1-online.png")
print("\n2) 切断服务端回源(模拟断网),强制刷新页面")
real_fetch = tilecache.TileCache._fetch
def offline_fetch(self, url): # noqa: ANN001
raise OSError("模拟断网:Network is unreachable")
tilecache.TileCache._fetch = offline_fetch
cdp.call("Page.reload", {"ignoreCache": True})
time.sleep(10)
stats_offline = dict(app.tiles.stats)
print(f" 离线统计:{stats_offline}")
warnings = cdp.eval(
"(function(){var w=document.querySelector('.map-warn');return w?w.textContent.trim():''})()"
)
canvas = cdp.eval(
"(function(){var c=document.querySelector('#map canvas');return c?JSON.stringify({w:c.width,h:c.height}):'none'})()"
)
check("断网后地图画布仍渲染", canvas not in ("none", "", None), str(canvas)[:80])
# 断网后新增的请求必须全部由磁盘缓存满足:不允许出现 failed / placeholder
delta_failed = stats_offline.get("failed", 0) - stats_online.get("failed", 0)
delta_hit = stats_offline.get("hit", 0) - stats_online.get("hit", 0)
check("断网后没有回源失败", delta_failed == 0, f"failed 增加 {delta_failed}")
check("断网后由缓存供图(hit 增加)", delta_hit > 0 or cached_after_online > 0,
f"hit {stats_online.get('hit')} → {stats_offline.get('hit')}")
check("界面未出现白屏级错误", "错误" not in str(cdp.eval("document.title")), "")
check("界面给出中文降级提示(若有缺失瓦片)",
warnings == "" or "瓦片" in warnings or "重试" in warnings, f"warn={warnings!r}")
# 用像素级判断:截图里非纯色像素占比说明底图确实画出来了
size = cdp.eval(
"(function(){var c=document.querySelector('#map canvas');if(!c)return null;"
"var ctx=c.getContext('2d');var d=ctx.getImageData(0,0,Math.min(400,c.width),Math.min(400,c.height)).data;"
"var seen={};for(var i=0;i<d.length;i+=4){seen[(d[i]>>4)+','+(d[i+1]>>4)+','+(d[i+2]>>4)]=1}"
"return Object.keys(seen).length})()"
)
check("地图像素有真实内容(非纯色/空白)", isinstance(size, int) and size > 20, f"颜色桶={size}")
cdp.shot("/tmp/offline-2-offline.png")
print("\n3) 恢复网络后自动回到在线出图")
tilecache.TileCache._fetch = real_fetch
cdp.call("Page.reload", {"ignoreCache": True})
time.sleep(8)
stats_restored = dict(app.tiles.stats)
check("恢复后重新回源", stats_restored.get("network", 0) >= stats_offline.get("network", 0),
str(stats_restored))
except Exception as exc:
FAIL += 1
FAILURES.append(f"异常 {exc}")
print(f" \033[31m✗\033[0m 异常:{exc.__class__.__name__}: {exc}")
import traceback
traceback.print_exc()
finally:
try:
tilecache.TileCache._fetch = tilecache.TileCache.__dict__.get("_fetch", None) or None
except Exception:
pass
if cdp:
cdp.close()
try:
import os
import signal
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
except Exception:
process.terminate()
app.shutdown()
try:
httpd.shutdown()
httpd.server_close()
except Exception:
pass
print(f"\n{'=' * 56}")
print(f"通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项")
print("截图:/tmp/offline-1-online.png、/tmp/offline-2-offline.png")
for item in FAILURES:
print(" -", item)
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""瓦片离线可用验证:回源成功后断网,验证缓存仍能供图。
做法:用真实的 core.tilecache.TileCache,先把 urllib 换成"永远失败"的实现(模拟断网),
再请求已经缓存过的瓦片,断言仍然返回 200 + 合法图片;同时断言未缓存过的瓦片会优雅降级
(返回占位图 + status != 200,由上层回 404 让前端走重试)。
运行:./.venv/bin/python tests/offline_tiles.py
"""
from __future__ import annotations
import sys
import time
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from core import config_store, tilecache # noqa: E402
PASS = 0
FAIL = 0
FAILURES: list[str] = []
def check(name: str, ok: bool, detail: str = "") -> None:
global PASS, FAIL
if ok:
PASS += 1
print(f" \033[32m✓\033[0m {name}")
else:
FAIL += 1
FAILURES.append(f"{name} {detail}")
print(f" \033[31m✗\033[0m {name} {detail}")
def main() -> int:
print("\033[1m瓦片本地缓存 / 离线可用验证\033[0m")
cache_dir = Path("/tmp/iosloc-offline-tiles")
import shutil
shutil.rmtree(cache_dir, ignore_errors=True)
source_key = "amap"
source = config_store.DEFAULT_TILE_SOURCES[source_key]
# 北京天安门附近、z12 —— 本机实测可达
z, x, y = 12, 3367, 1580
cache = tilecache.TileCache(cache_dir, max_mb=64)
print("\n1) 联网回源并落盘")
online = cache.get_tile(source_key, source, z, x, y)
check("回源返回真实图片", online.status == 200 and online.body[:4] in (b"\x89PNG", b"\xff\xd8"),
f"status={online.status} source={online.source}")
check("标记为 network", online.source == "network", online.source)
check("已写入磁盘缓存", any(cache_dir.rglob("*")), "缓存目录为空")
print("\n2) 断网(把 urllib 换成永远失败)后请求同一瓦片")
real_urlopen = urllib.request.urlopen
def offline(*_args, **_kwargs):
raise OSError("模拟断网:Network is unreachable")
urllib.request.urlopen = offline
try:
cached = cache.get_tile(source_key, source, z, x, y)
check("离线仍返回 200", cached.status == 200, f"status={cached.status}")
check("来源为 cache", cached.source == "cache", cached.source)
check("图片内容与联网时一致", cached.body == online.body,
f"{len(cached.body)}B vs {len(online.body)}B")
print("\n3) 断网 + 未缓存瓦片 → 优雅降级(不抛异常)")
t0 = time.time()
miss = cache.get_tile(source_key, source, z, x, y + 1)
elapsed = time.time() - t0
check("未命中不抛异常", True, "")
check("返回占位图且 status!=200(上层回 404 让前端重试)",
miss.status != 200 and miss.body[:4] == b"\x89PNG",
f"status={miss.status} source={miss.source}")
check("失败快速返回(<5s)", elapsed < 5.0, f"{elapsed:.2f}s")
print("\n4) 缓存统计")
stats = cache.stats
check("命中计数增加", stats["hit"] >= 1, str(stats))
check("统计到离线失败", stats["failed"] >= 1, str(stats))
finally:
urllib.request.urlopen = real_urlopen
print("\n5) 恢复网络后仍可正常回源")
cache2 = tilecache.TileCache(cache_dir, max_mb=64)
other = cache2.get_tile(source_key, source, z, x + 1, y)
check("恢复后回源成功", other.status == 200 and other.body[:4] in (b"\x89PNG", b"\xff\xd8"),
f"status={other.status} source={other.source}")
print("\n6) 容量上限淘汰不破坏可用性")
small = tilecache.TileCache(cache_dir, max_mb=1)
small._sweep()
check("淘汰后仍能读取", small.get_tile(source_key, source, z, x, y).status in (200, 404))
shutil.rmtree(cache_dir, ignore_errors=True)
print(f"\n{'=' * 56}")
print(f"通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项")
for item in FAILURES:
print(" -", item)
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""真机端到端验证:连接 iOS 设备 → 建立隧道 → 沿轨迹播放虚拟定位 → 停止恢复真实定位。
这是**会真的改变设备定位**的测试(结束后会 clear_location 恢复真实定位)。
只有在你明确同意时才运行:
./.venv/bin/python tests/real_device_e2e.py --confirm
./.venv/bin/python tests/real_device_e2e.py --confirm --udid 00008030-...
./.venv/bin/python tests/real_device_e2e.py --confirm --duration 6 --speed 20
"""
from __future__ import annotations
import argparse
import sys
import threading
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tests"))
from core import protocol, server as server_mod # noqa: E402
from selftest import WSClient # noqa: E402
PASS = 0
FAIL = 0
def check(name: str, ok: bool, detail: str = "") -> None:
global PASS, FAIL
if ok:
PASS += 1
print(f" \033[32m✓\033[0m {name}")
else:
FAIL += 1
print(f" \033[31m✗\033[0m {name} {detail}")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--confirm", action="store_true", help="确认会真的改变设备定位")
parser.add_argument("--udid", default=None, help="指定设备 UDID(默认用发现的唯一 USB 设备)")
parser.add_argument("--port", type=int, default=8811)
parser.add_argument("--duration", type=float, default=6.0, help="播放时长(秒)")
parser.add_argument("--speed", type=float, default=20.0, help="播放倍速")
parser.add_argument("--keep", action="store_true", help="结束后不恢复真实定位(默认会恢复)")
args = parser.parse_args()
if not args.confirm:
print("这是真机测试,会临时改变 iPhone 的定位。请加 --confirm 运行。")
return 2
print("\033[1miOS 虚拟轨迹 — 真机端到端验证\033[0m")
app = server_mod.App(root=ROOT, mock=False, port=args.port)
devices = app.devices.refresh()
if not devices:
print(" \033[31m✗\033[0m 未发现设备")
return 1
print(" 发现的设备:")
for d in devices:
print(f" - {d.name} | iOS {d.ios_version} | {d.connection} | 开发者模式={d.developer_mode} | {d.udid}")
target = None
for d in devices:
if args.udid and d.udid != args.udid:
continue
if not args.udid and d.connection != "usb":
continue
target = d
break
target = target or devices[0]
print(f" 目标设备:{target.name}(iOS {target.ios_version})\n")
app.devices.select(target.udid)
httpd = server_mod.serve(app, host="127.0.0.1", port=args.port)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
time.sleep(0.4)
client = WSClient("127.0.0.1", args.port, timeout=120)
exit_code = 0
try:
client.send({"type": "hello", "id": "h1", "client": "e2e"})
ack = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "h1", timeout=20)
check("连接服务端", ack is not None)
print("\n → 建立设备会话(iOS 17+ 会自动建 RSD 隧道,最长约 75 秒)…")
started = time.time()
client.send({"type": "device.connect", "id": "c1"})
connected = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "c1", timeout=180)
took = time.time() - started
ok = bool(connected and connected.get("ok", True))
check(f"设备连接成功({took:.1f}s)", ok, str(connected)[:400])
if not ok:
print(f" 错误:{connected}")
return 1
probe = connected.get("probe") or {}
check("DVT 自检通过", bool(probe.get("ok")), str(probe)[:300])
print(f" 自检详情:{probe.get('detail')}")
print("\n → 单点定位测试…")
client.send({"type": "location.set", "id": "l1", "lat": 39.9088, "lng": 116.3975})
ack = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "l1", timeout=30)
check("单点定位下发", bool(ack and ack.get("ok", True)), str(ack)[:300])
location = client.wait_for(lambda m: m.get("type") == "location" and m.get("source") == "manual", timeout=15)
check("收到单点 location 事件", location is not None, str(location)[:200])
print(f"\n → 轨迹播放测试({args.duration:.0f} 秒,倍速 {args.speed}x)…")
route = app.routes.build(
[[116.3975, 39.9088], [116.4050, 39.9120], [116.4120, 39.9080]],
mode="driving", use_osrm=False,
)
print(f" 轨迹:{route.distance / 1000:.2f} 公里 / {len(route.path)} 点 / 预计 {route.duration:.0f} 秒")
client.send({"type": "play.start", "id": "s1", "route": route.to_json(),
"speed_scale": args.speed, "loop": False})
ack = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "s1", timeout=30)
check("播放启动", bool(ack and ack.get("ok", True)), str(ack)[:300])
first = client.wait_for(lambda m: m.get("type") == "location" and m.get("source") == "play", timeout=20)
check("播放下发首个定位", first is not None, str(first)[:200])
progressed = client.wait_for(lambda m: m.get("type") == "location" and m.get("progress", 0) > 0.1, timeout=30)
check("定位沿轨迹推进", progressed is not None, str(progressed)[:200])
if progressed:
print(f" 当前进度 {progressed.get('progress'):.1%} → {progressed.get('lat')}, {progressed.get('lng')}")
sent_before = None
with client.lock:
sent_before = len([m for m in client.messages if m.get("type") == "location" and m.get("source") == "play"])
time.sleep(args.duration)
with client.lock:
sent_after = len([m for m in client.messages if m.get("type") == "location" and m.get("source") == "play"])
expect = args.duration * float(app.config.get("send_hz") or 5) * 0.5
check(f"持续下发定位({sent_after - sent_before} 次 / {args.duration:.0f}s)",
(sent_after - sent_before) >= expect, f"期望 ≥{expect:.0f} 次")
client.send({"type": "play.pause", "id": "p1"})
paused = client.wait_for(lambda m: m.get("type") == "status" and m.get("paused") is True, timeout=15)
check("暂停", paused is not None, str(paused)[:200])
client.send({"type": "play.resume", "id": "r1"})
resumed = client.wait_for(lambda m: m.get("type") == "status" and m.get("paused") is False and m.get("playing") is True, timeout=15)
check("继续", resumed is not None, str(resumed)[:200])
if not args.keep:
print("\n → 停止并恢复真实定位…")
client.send({"type": "play.stop", "id": "st1"})
stopped = client.wait_for(lambda m: m.get("type") == "status" and m.get("playing") is False, timeout=25)
check("停止播放", stopped is not None, str(stopped)[:200])
cleared = client.wait_for(lambda m: m.get("type") == "location" and m.get("source") == "cleared", timeout=20)
check("已恢复设备真实定位", cleared is not None, str(cleared)[:200])
except Exception as exc:
print(f" \033[31m✗\033[0m 异常:{exc.__class__.__name__}: {exc}")
import traceback
traceback.print_exc()
FAIL += 1
finally:
try:
if not args.keep:
app.playback.stop(notify=False, clear_provider=True)
except Exception:
pass
client.close()
app.shutdown()
try:
httpd.shutdown()
httpd.server_close()
except Exception:
pass
print(f"\n{'=' * 50}\n通过 {PASS} 项,失败 {FAIL} 项")
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""真机图形界面验收:在真实设备模式下驱动界面完成绘制→规划→播放,并截图留证。
与 tests/real_device_e2e.py 的区别:这个是**通过浏览器点击界面**完成的(端到端,
包含前端),而 real_device_e2e.py 是直接走 WS 协议。
./.venv/bin/python tests/real_gui_acceptance.py --confirm --url http://127.0.0.1:8790/
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tests"))
from ui_acceptance import CDP, launch_chrome # noqa: E402
PASS = 0
FAIL = 0
FAILURES: list[str] = []
def check(name: str, ok: bool, detail: str = "") -> None:
global PASS, FAIL
if ok:
PASS += 1
print(f" \033[32m✓\033[0m {name}")
else:
FAIL += 1
FAILURES.append(f"{name} {detail}")
print(f" \033[31m✗\033[0m {name} {detail}")
def main() -> int:
global PASS, FAIL
parser = argparse.ArgumentParser()
parser.add_argument("--confirm", action="store_true", help="确认会真的改变设备定位")
parser.add_argument("--url", default="http://127.0.0.1:8790/")
parser.add_argument("--out", default="/tmp/ui-real-acceptance")
parser.add_argument("--port", type=int, default=9466)
parser.add_argument("--wait-connect", type=float, default=90.0)
args = parser.parse_args()
if not args.confirm:
print("真机测试会改变设备定位,请加 --confirm")
return 2
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
print("\033[1m真机图形界面验收\033[0m")
process, ws_url = launch_chrome(1440, 900, args.port)
cdp = CDP(ws_url, timeout=180)
try:
cdp.call("Page.enable")
cdp.call("Runtime.enable")
cdp.call("Emulation.setDeviceMetricsOverride",
{"width": 1440, "height": 900, "deviceScaleFactor": 2, "mobile": False})
cdp.call("Page.navigate", {"url": args.url})
time.sleep(4.0)
check("界面已连接服务端",
"已连接" in str(cdp.eval("document.querySelector('#connPill').textContent")),
str(cdp.eval("document.querySelector('#connPill').textContent")))
device_text = str(cdp.eval("(function(){var d=document.querySelector('#deviceCard');return d?d.innerText.replace(/\\n/g,' | '):''})()"))
check("设备卡片识别到真机", "iPhone" in device_text, device_text[:160])
print(f" 设备卡片:{device_text[:120]}")
cdp.shot(out_dir / "01-device-found.png")
# 点「连接设备」
cdp.eval("(function(){var b=document.querySelector('#btnDeviceConnect');if(b)b.click();return !!b})()")
print(" → 正在连接设备(iOS 17+ 会建立 RSD 隧道,最长约 90 秒)…")
connected = False
deadline = time.time() + args.wait_connect
while time.time() < deadline:
time.sleep(1.0)
card = str(cdp.eval("(function(){var d=document.querySelector('#deviceCard');return d?d.innerText:''})()"))
if "模拟中" in card or "已连接" in card and "虚拟定位" in card:
if "模拟中" in card or "就绪" in card:
connected = True
break
card_final = str(cdp.eval("(function(){var d=document.querySelector('#deviceCard');return d?d.innerText.replace(/\\n/g,' | '):''})()"))
check("设备连接成功(卡片显示虚拟定位状态)", connected, card_final[:200])
print(f" 连接后卡片:{card_final[:160]}")
cdp.shot(out_dir / "02-connected.png")
# 绘制 3 个途经点
cdp.eval("(function(){var b=document.querySelector('#toolAdd');if(b)b.click();return !!b})()")
time.sleep(0.4)
box = json.loads(cdp.eval("(function(){var r=document.querySelector('#map').getBoundingClientRect();"
"return JSON.stringify({x:r.x,y:r.y,w:r.width,h:r.height})})()"))
for fx, fy in ((0.28, 0.30), (0.50, 0.48), (0.74, 0.36)):
cdp.click(box["x"] + box["w"] * fx, box["y"] + box["h"] * fy)
time.sleep(0.45)
wp = cdp.eval("(function(){return document.querySelectorAll('#wpList .wp-item, #wpList li, #wpList [data-index]').length})()")
check("地图点选生成途经点", int(wp or 0) >= 3, f"途经点={wp}")
# 规划路线
cdp.eval("(function(){var b=document.querySelector('#btnBuildRoute');if(b)b.click();return !!b})()")
distance = "—"
for _ in range(40):
time.sleep(0.5)
distance = str(cdp.eval("document.querySelector('#statDistance').textContent"))
if distance not in ("—", "-", "", "0"):
break
check("OSRM 路线规划成功", distance not in ("—", "-", "", "0"), f"总距离={distance!r}")
cdp.shot(out_dir / "03-route.png")
# 倍速滑块是对数刻度(0.1x ~ 50x),按 Geo.speedToSlider 反算 1.0x 对应的滑块位置
slider_value = int(round((__import__("math").log(1.0 / 0.1) / __import__("math").log(50.0 / 0.1)) * 1000))
cdp.eval("(function(){var s=document.querySelector('#speedRange');if(!s)return 'no';"
f"s.value={slider_value};s.dispatchEvent(new Event('input',{{bubbles:true}}));"
"s.dispatchEvent(new Event('change',{bubbles:true}));return s.value})()")
time.sleep(0.8)
speed_label = str(cdp.eval("document.querySelector('#speedValue').textContent"))
check("倍速已设为 1.0x(对数滑块)", "1.0x" in speed_label or "1x" in speed_label, speed_label)
# 播放
cdp.eval("(function(){var b=document.querySelector('#btnPlay');if(b)b.click();return !!b})()")
progressed = False
progress_text = ""
for _ in range(30):
time.sleep(0.5)
progress_text = str(cdp.eval("document.querySelector('#chipProgress').textContent"))
coord = str(cdp.eval("document.querySelector('#chipCoord').textContent"))
if any(ch.isdigit() for ch in progress_text) and "0%" not in progress_text and "0 %" not in progress_text:
progressed = True
break
check("真机播放推进", progressed, f"progress={progress_text!r}")
coord = str(cdp.eval("document.querySelector('#chipCoord').textContent"))
check("界面显示实时下发坐标", any(ch.isdigit() for ch in coord), f"coord={coord!r}")
print(f" 实时状态:{progress_text} | {coord}")
cdp.shot(out_dir / "04-playing-real-direct.png")
# 日志面板展开,确认有真实下发日志
cdp.eval("(function(){var b=document.querySelector('#btnToggleLog');if(b)b.click();return !!b})()")
time.sleep(1.2)
log_text = str(cdp.eval("(function(){var p=document.querySelector('#logBody');return p?p.innerText:''})()"))
check("日志面板记录了真机下发", "下发" in log_text or "定位" in log_text, log_text[-200:].replace("\n", " | "))
cdp.shot(out_dir / "05-logs-real.png")
# 停止并恢复真实定位
cdp.eval("(function(){var b=document.querySelector('#btnStop');if(b)b.click();return !!b})()")
time.sleep(2.5)
state = str(cdp.eval("document.querySelector('#chipPlayState').textContent"))
check("停止后回到未播放", "未播放" in state or "已停止" in state or "已结束" in state, state)
cdp.shot(out_dir / "06-stopped.png")
errors = []
for event in cdp.events:
if event.get("method") == "Runtime.exceptionThrown":
detail = event["params"]["exceptionDetails"]
errors.append(f"{detail.get('text')} {(detail.get('exception') or {}).get('description', '')[:160]}")
elif event.get("method") == "Runtime.consoleAPICalled" and event["params"].get("type") == "error":
parts = [str(a.get("value", a.get("description", "")))[:140] for a in event["params"].get("args", [])]
errors.append("console.error: " + " ".join(parts))
check("真机流程中页面无 JS 报错", not errors, " | ".join(errors[:3]))
except Exception as exc:
FAIL += 1
FAILURES.append(f"异常 {exc}")
print(f" \033[31m✗\033[0m 异常:{exc.__class__.__name__}: {exc}")
import traceback
traceback.print_exc()
finally:
try:
cdp.close()
except Exception:
pass
try:
os.killpg(os.getpgid(process.pid), 15)
except Exception:
process.terminate()
print(f"\n{'=' * 56}")
print(f"真机图形界面验收:通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项")
print(f"截图目录:{out_dir}")
for item in FAILURES:
print(" -", item[:200])
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())
+496
View File
@@ -0,0 +1,496 @@
#!/usr/bin/env python3
"""端到端自检:单元 + 集成(无设备,走 --mock)。
运行:./.venv/bin/python tests/selftest.py
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import socket
import struct
import sys
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from core import config_store, geoutils, protocol, route_engine, tilecache, ws # noqa: E402
PASS = 0
FAIL = 0
FAILURES: list[str] = []
def check(name: str, condition: bool, detail: str = "") -> None:
global PASS, FAIL
if condition:
PASS += 1
print(f" \033[32m✓\033[0m {name}")
else:
FAIL += 1
FAILURES.append(f"{name} {detail}")
print(f" \033[31m✗\033[0m {name} {detail}")
def section(title: str) -> None:
print(f"\n\033[1m{title}\033[0m")
# ---------------------------------------------------------------- 单元
def test_protocol() -> None:
section("1. 协议与校验")
check("合法经纬度通过", protocol.validate_latlng(39.9, 116.4) == (39.9, 116.4))
for bad in [(91, 0), (0, 181), ("a", 1), (float("nan"), 0)]:
try:
protocol.validate_latlng(*bad)
check(f"非法经纬度被拒 {bad}", False)
except protocol.AppError:
check(f"非法经纬度被拒 {bad}", True)
check("倍速上下限", protocol.validate_speed_scale(1.5) == 1.5)
try:
protocol.validate_speed_scale(999)
check("倍速超限被拒", False)
except protocol.AppError:
check("倍速超限被拒", True)
check("模式解析", protocol.validate_mode("cycling") == "cycling")
check("JSON 中文不被转义", "定位" in protocol.dumps({"msg": "定位"}))
def test_geo() -> None:
section("2. 地理计算")
d = geoutils.haversine(39.9, 116.4, 39.9, 116.41)
check("haversine 精度", 830 < d < 870, f"got {d:.1f}m")
b = geoutils.bearing(39.9, 116.4, 40.0, 116.4)
check("正北方位角≈0", b < 1 or b > 359, f"got {b:.2f}")
path = [(39.9, 116.4), (39.91, 116.41)]
dense = geoutils.densify(path, 20.0)
check("路径加密点变多", len(dense) > 50, f"{len(dense)} 点")
acc = geoutils.cumulative_lengths(dense)
total = acc[-1]
check("累计弧长单调递增", all(acc[i] <= acc[i + 1] for i in range(len(acc) - 1)))
mid, _ = geoutils.interpolate(dense, acc, total / 2)
check("弧长中点落在起终点之间", 39.9 <= mid[0] <= 39.91, f"{mid}")
simplified = geoutils.simplify(dense, 2.0)
check("简化后点数下降且首尾保留", len(simplified) < len(dense) and simplified[0] == dense[0])
def test_route_engine() -> None:
section("3. 路线引擎")
config = config_store.ConfigStore(ROOT / "config.json")
engine = route_engine.RouteEngine(config)
route = engine.build([[116.40, 39.90], [116.41, 39.905], [116.42, 39.90]], mode="driving", use_osrm=False)
check("直线兜底引擎标记", route.engine == "straight")
check("路径非空且已加密", len(route.path) > 100, f"{len(route.path)} 点")
check("距离 > 1.5km", route.distance > 1500, f"{route.distance:.0f}m")
check("时长合理", 60 < route.duration < 1200, f"{route.duration:.0f}s")
check("速度剖面长度一致", len(route.speeds) == len(route.path))
check("速度均为正", all(v > 0 for v in route.speeds))
mid = route.point_at_distance(route.distance / 2)
check("中点取点有效", 39.89 <= mid[0] <= 39.91, f"{mid}")
payload = route.to_json()
check("JSON 坐标是 [lng,lat]", abs(payload["path"][0][0] - 116.40) < 0.01, str(payload["path"][0]))
restored = route_engine.sanitize_route_payload(payload)
check("route 往返一致", abs(restored.distance - route.distance) < 1.0, f"{restored.distance:.1f} vs {route.distance:.1f}")
check("高亮子路径不超过全长", len(route.highlight(route.distance / 2)) <= len(route.path))
# GPX 往返
gpx = route_engine.to_gpx(route, creator="selftest")
check("GPX 含 trkpt", "<trkpt" in gpx and gpx.startswith("<?xml"))
coords, name, _ = route_engine.from_gpx(gpx)
check("GPX 导入保形(不抽稀丢点)", len(coords) == len(route.path), f"{len(coords)} vs {len(route.path)}")
total = geoutils.cumulative_lengths(coords)[-1]
check("GPX 往返距离误差 < 2m", abs(total - route.distance) < 2.0, f"误差 {abs(total - route.distance):.3f}m")
# 单点与异常
single = engine.build([[116.40, 39.90]], mode="walking", use_osrm=False)
check("单点轨迹可用", len(single.path) == 1 and single.distance == 0)
for bad, label in [([], "空途经点"), ([[200, 39]], "非法经度")]:
try:
engine.build(bad, use_osrm=False)
check(f"{label}被拒", False)
except protocol.AppError:
check(f"{label}被拒", True)
# OSRM URL 生成(不依赖外网)
check("flight 不走 OSRM", engine._osrm([(39.9, 116.4), (39.91, 116.41)], "flight") is None)
def test_tiles() -> None:
section("4. 瓦片与缓存")
source = config_store.DEFAULT_TILE_SOURCES["amap"]
url1 = tilecache.build_url(source, 12, 3367, 1580)
check("高德 URL 生成", url1.startswith("https://webrd0") and "{x}" not in url1, url1)
url2 = tilecache.build_url(source, 12, 3368, 1580)
check("子域轮换生效", url1.split("/")[2] != url2.split("/")[2], f"{url1.split('/')[2]} vs {url2.split('/')[2]}")
arc = config_store.DEFAULT_TILE_SOURCES["arcgis_sat"]
check("Esri URL 含 z/y/x", "/tile/12/1580/3367" in tilecache.build_url(arc, 12, 3367, 1580))
check("瓦片坐标校验", tilecache.validate_tile_request(12, 3367, 1580) == (12, 3367, 1580))
try:
tilecache.validate_tile_request(12, 99999, 1)
check("越界瓦片被拒", False)
except protocol.AppError:
check("越界瓦片被拒", True)
tmp = ROOT / ".cache-test"
cache = tilecache.TileCache(tmp, max_mb=1)
result = cache.get_tile("amap", source, 12, 0, 0)
check("瓦片请求返回图片", result.body[:4] in (b"\x89PNG", b"\xff\xd8") and result.content_type.startswith("image/"),
f"{result.status} {result.source} {len(result.body)}B")
check("瓦片已落盘缓存", any(tmp.rglob("*.png")) or any(tmp.rglob("*.jpg")))
result2 = cache.get_tile("amap", source, 12, 0, 0)
check("第二次命中缓存", result2.source == "cache", result2.source)
removed = cache.clear()
check("清理缓存", removed >= 1, f"removed={removed}")
try:
import shutil
shutil.rmtree(tmp, ignore_errors=True)
except Exception:
pass
def test_config() -> None:
section("5. 配置")
path = ROOT / ".config-test.json"
path.unlink(missing_ok=True)
store = config_store.ConfigStore(path)
check("默认瓦片源为高德", store.get("tile_source") == "amap")
try:
store.update({"send_hz": 999})
check("非法 send_hz 被拒(不再静默夹取)", False)
except protocol.AppError as exc:
check("非法 send_hz 被拒(不再静默夹取)", exc.code == "bad_config", exc.code)
store.update({"send_hz": 8})
check("合法 send_hz 生效", store.get("send_hz") == 8.0, str(store.get("send_hz")))
store.update({"tile_source": "amap_sat"})
store2 = config_store.ConfigStore(path)
check("配置持久化", store2.get("tile_source") == "amap_sat")
try:
store.update({"不存在的项": 1})
check("未知配置被拒", False)
except protocol.AppError:
check("未知配置被拒", True)
sources = store.tile_sources()
check("瓦片源齐全", all(k in sources for k in ("amap", "amap_sat", "arcgis_sat", "osm")))
path.unlink(missing_ok=True)
# ---------------------------------------------------------------- WS 客户端
class WSClient:
def __init__(self, host: str, port: int, timeout: float = 30.0) -> None:
self.sock = socket.create_connection((host, port), timeout=timeout)
key = base64.b64encode(os.urandom(16)).decode()
request = (
f"GET /ws HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
)
self.sock.sendall(request.encode())
header = b""
while b"\r\n\r\n" not in header:
chunk = self.sock.recv(4096)
if not chunk:
raise RuntimeError("握手失败")
header += chunk
if b"101" not in header.split(b"\r\n")[0]:
raise RuntimeError(f"握手未升级:{header[:200]!r}")
expected = base64.b64encode(hashlib.sha1((key + ws.GUID).encode()).digest()).decode()
if expected.encode() not in header:
raise RuntimeError("Sec-WebSocket-Accept 校验失败")
self.buf = header.split(b"\r\n\r\n", 1)[1]
self.messages: list[dict] = []
self.lock = threading.Lock()
self._stop = threading.Event()
self.reader = threading.Thread(target=self._read_loop, daemon=True)
self.reader.start()
def _recv_exact(self, n: int) -> bytes | None:
while len(self.buf) < n:
try:
chunk = self.sock.recv(65536)
except OSError:
return None
if not chunk:
return None
self.buf += chunk
out, self.buf = self.buf[:n], self.buf[n:]
return out
def _read_loop(self) -> None:
while not self._stop.is_set():
head = self._recv_exact(2)
if head is None:
return
opcode = head[0] & 0x0F
length = head[1] & 0x7F
if length == 126:
ext = self._recv_exact(2)
if ext is None:
return
length = struct.unpack("!H", ext)[0]
elif length == 127:
ext = self._recv_exact(8)
if ext is None:
return
length = struct.unpack("!Q", ext)[0]
payload = self._recv_exact(length) if length else b""
if payload is None:
return
if opcode == 0x8:
return
if opcode in (0x9, 0xA):
continue
try:
with self.lock:
self.messages.append(json.loads(payload.decode("utf-8")))
except Exception:
continue
def send(self, obj: dict) -> None:
payload = json.dumps(obj, ensure_ascii=False).encode()
header = bytearray([0x81])
mask = os.urandom(4)
length = len(payload)
if length < 126:
header.append(0x80 | length)
elif length < 65536:
header.append(0x80 | 126)
header.extend(struct.pack("!H", length))
else:
header.append(0x80 | 127)
header.extend(struct.pack("!Q", length))
header.extend(mask)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
self.sock.sendall(bytes(header) + masked)
def wait_for(self, predicate, timeout: float = 15.0) -> dict | None:
deadline = time.time() + timeout
seen = 0
while time.time() < deadline:
with self.lock:
batch = self.messages[seen:]
seen = len(self.messages)
for message in batch:
if predicate(message):
return message
time.sleep(0.05)
return None
def close(self) -> None:
self._stop.set()
try:
self.sock.close()
except OSError:
pass
def test_integration() -> None:
section("6. 端到端(--mock 模式,真 HTTP + 真 WebSocket)")
from core import server as server_mod
port = 8899
app = server_mod.App(root=ROOT, mock=True, port=port)
app.devices.refresh()
httpd = server_mod.serve(app, host="127.0.0.1", port=port)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{port}"
time.sleep(0.4)
try:
# --- REST ---
with urllib.request.urlopen(base + "/api/health", timeout=5) as response:
health = json.loads(response.read())
check("GET /api/health", health.get("ok") and health.get("version") == protocol.APP_VERSION, str(health)[:120])
check("mock 模式标记", health.get("mock") is True)
with urllib.request.urlopen(base + "/api/devices", timeout=5) as response:
devices = json.loads(response.read())
check("GET /api/devices 返回模拟设备", len(devices["devices"]) == 1 and devices["devices"][0]["ios_major"] == 26,
str(devices)[:150])
with urllib.request.urlopen(base + "/api/settings/tiles", timeout=5) as response:
tiles = json.loads(response.read())
keys = {item["key"] for item in tiles["sources"]}
check("瓦片源接口含 key/datum", "amap" in keys and tiles["sources"][0]["datum"] in ("gcj02", "wgs84"), str(tiles)[:150])
check("瓦片源三种字段名都在", tiles["current"] == tiles["tile_source"] == tiles["selected"])
with urllib.request.urlopen(base + "/", timeout=5) as response:
html = response.read().decode("utf-8", "replace")
check("GET / 返回前端页面", "<html" in html.lower(), html[:80])
# 路线规划(REST)
body = json.dumps({"waypoints": [[116.40, 39.90], [116.41, 39.905], [116.42, 39.90]], "mode": "driving"}).encode()
request = urllib.request.Request(base + "/api/route/build", data=body, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(request, timeout=30) as response:
route_result = json.loads(response.read())
check("POST /api/route/build", route_result.get("ok") and route_result["route"]["distance"] > 500,
str(route_result)[:120])
# GPX 往返(REST)
export_body = json.dumps({"route": route_result["route"], "name": "自检轨迹"}).encode()
request = urllib.request.Request(base + "/api/gpx/export", data=export_body, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(request, timeout=15) as response:
gpx_text = response.read().decode()
check("POST /api/gpx/export", "<trkpt" in gpx_text and "自检轨迹" in gpx_text)
request = urllib.request.Request(base + "/api/gpx/import", data=gpx_text.encode(), headers={"Content-Type": "application/gpx+xml"})
with urllib.request.urlopen(request, timeout=15) as response:
imported = json.loads(response.read())
check("POST /api/gpx/import", imported["ok"] and imported["count"] >= 2, str(imported)[:120])
check("GPX 导入不丢几何(返回全量点)", len(imported["waypoints"]) == imported["count"] >= 100,
f"count={imported['count']}")
check("GPX 导入同时提供 path/points 全量数组",
imported.get("path") == imported["waypoints"] and imported.get("points") == imported["waypoints"])
imported_coords = [(p[1], p[0]) for p in imported["waypoints"]]
imported_total = geoutils.cumulative_lengths(imported_coords)[-1]
check("GPX 导入距离保真(误差 < 2m)", abs(imported_total - route_result["route"]["distance"]) < 2.0,
f"误差 {abs(imported_total - route_result['route']['distance']):.3f}m")
# 瓦片代理
with urllib.request.urlopen(base + "/api/tiles/amap/12/3367/1580.png", timeout=25) as response:
tile_bytes = response.read()
check("GET /api/tiles 返回图片", tile_bytes[:4] in (b"\x89PNG", b"\xff\xd8"), f"{len(tile_bytes)}B")
# --- WebSocket ---
client = WSClient("127.0.0.1", port)
client.send({"type": "hello", "id": "h1", "client": "selftest"})
ack = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "h1")
check("WS hello ack 携带 welcome 数据", ack is not None and ack.get("mock") is True and "config" in ack,
str(ack)[:140])
check("welcome 里带设备与隧道快照", ack is not None and "tunnel" in ack and ack.get("version") == protocol.APP_VERSION,
str(ack)[:140])
client.send({"type": "ping", "id": "p1"})
pong = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "p1")
check("WS ping→ack(ts)", pong is not None and "ts" in pong, str(pong)[:120])
client.send({"type": "device.connect", "id": "c1"})
connected = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "c1", timeout=20)
check("WS device.connect", connected is not None and connected.get("ok", True), str(connected)[:160])
client.send({"type": "location.set", "id": "l1", "lat": 39.9088, "lng": 116.3975})
ack = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "l1", timeout=15)
check("WS location.set", ack is not None and ack.get("ok", True), str(ack)[:160])
location = client.wait_for(lambda m: m.get("type") == "location" and m.get("source") == "manual", timeout=10)
check("收到 location 事件(单点)", location is not None and abs(location["lng"] - 116.3975) < 1e-6, str(location)[:160])
# 多设备/非法命令
client.send({"type": "play.start", "id": "e1", "route": {"path": [], "waypoints": []}})
bad = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "e1", timeout=10)
check("空轨迹被拒并回中文错误", bad is not None and bad.get("ok") is False and "轨迹" in bad.get("error", ""), str(bad)[:160])
# 播放流程
route_payload = route_result["route"]
client.send({"type": "play.start", "id": "s1", "route": route_payload, "speed_scale": 8.0, "loop": False})
started = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "s1", timeout=20)
check("WS play.start", started is not None and started.get("ok", True), str(started)[:160])
first = client.wait_for(lambda m: m.get("type") == "location" and m.get("source") == "play", timeout=10)
check("播放下发首个定位", first is not None and first.get("progress", 1) < 0.2, str(first)[:160])
progressed = client.wait_for(lambda m: m.get("type") == "location" and m.get("progress", 0) > 0.05, timeout=20)
check("播放推进(进度增长)", progressed is not None, str(progressed)[:160])
status = client.wait_for(lambda m: m.get("type") == "status" and m.get("playing") is True, timeout=10)
check("status 事件含剩余时间", status is not None and "remaining" in status, str(status)[:160])
client.send({"type": "play.pause", "id": "p2"})
paused = client.wait_for(lambda m: m.get("type") == "status" and m.get("paused") is True, timeout=10)
check("暂停生效", paused is not None, str(paused)[:120])
time.sleep(0.6)
with client.lock:
after_pause = len([m for m in client.messages if m.get("type") == "location" and m.get("source") == "play"])
time.sleep(0.8)
with client.lock:
still = len([m for m in client.messages if m.get("type") == "location" and m.get("source") == "play"])
check("暂停后不再下发", still - after_pause <= 1, f"{after_pause} → {still}")
client.send({"type": "play.resume", "id": "r1"})
resumed = client.wait_for(lambda m: m.get("type") == "status" and m.get("paused") is False and m.get("playing") is True, timeout=10)
check("继续生效", resumed is not None, str(resumed)[:120])
client.send({"type": "play.speed", "id": "sp1", "speed_scale": 20.0})
fast = client.wait_for(lambda m: m.get("type") == "status" and m.get("speed_scale") == 20.0, timeout=10)
check("倍速调整生效", fast is not None, str(fast)[:120])
client.send({"type": "play.seek", "id": "sk1", "progress": 0.5})
seeked = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "sk1", timeout=10)
check("seek 生效", seeked is not None and seeked.get("ok", True), str(seeked)[:120])
at_half = client.wait_for(lambda m: m.get("type") == "location" and m.get("progress", 0) > 0.45, timeout=10)
check("seek 后位置过半", at_half is not None, str(at_half)[:140])
# 播放到结束(循环 + 高倍速)
client.send({"type": "play.speed", "id": "sp2", "speed_scale": 50.0})
client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "sp2", timeout=10)
done = client.wait_for(
lambda m: m.get("type") == "status" and m.get("playing") is False and m.get("progress", 0) >= 0.999,
timeout=90,
)
check("轨迹播放到终点", done is not None, str(done)[:160])
# 循环模式
client.send({"type": "play.start", "id": "s2", "route": route_payload, "speed_scale": 50.0, "loop": True})
client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "s2", timeout=15)
wrapped = client.wait_for(
lambda m: m.get("type") == "status" and m.get("loop") is True and m.get("progress", 1) < 0.5 and m.get("playing"),
timeout=90,
)
check("loop 模式回到起点继续", wrapped is not None, str(wrapped)[:160])
client.send({"type": "play.stop", "id": "st1"})
stopped = client.wait_for(lambda m: m.get("type") == "status" and m.get("playing") is False, timeout=15)
check("停止播放", stopped is not None, str(stopped)[:140])
cleared = client.wait_for(lambda m: m.get("type") == "location" and m.get("source") == "cleared", timeout=10)
check("停止后通知恢复真实定位", cleared is not None, str(cleared)[:120])
client.send({"type": "log.list", "id": "lg1", "limit": 50})
logs = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "lg1", timeout=10)
check("日志回填", logs is not None, str(logs)[:120])
client.send({"type": "不存在的命令", "id": "x1"})
unknown = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "x1", timeout=10)
check("未知命令回中文错误", unknown is not None and unknown.get("ok") is False, str(unknown)[:140])
client.close()
time.sleep(0.3)
finally:
app.shutdown()
try:
httpd.shutdown()
httpd.server_close()
except Exception:
pass
def main() -> int:
print(f"\033[1m{protocol.APP_NAME} 自检\033[0m (Python {sys.version.split()[0]})")
started = time.time()
test_protocol()
test_geo()
test_route_engine()
test_tiles()
test_config()
try:
test_integration()
except Exception as exc:
global FAIL
FAIL += 1
FAILURES.append(f"集成测试异常:{exc}")
print(f" \033[31m✗\033[0m 集成测试异常:{exc.__class__.__name__}: {exc}")
import traceback
traceback.print_exc()
print(f"\n{'=' * 56}")
print(f"通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项,用时 {time.time() - started:.1f}s")
if FAILURES:
print("\n失败明细:")
for item in FAILURES:
print(f" - {item}")
return 1
print("\033[32m全部通过\033[0m")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+419
View File
@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""图形界面验收:用真实 Chrome 驱动界面完成一整套操作并截图。
做的是**图形界面级**验收:在页面上真实点击「添加途经点」→ 在地图上点三个点 →
点「加载 OSRM 路线」→ 点「播放」→ 截图,并断言界面状态与后端状态一致。
CDP 走 WebSocket,本机没有 websocket-client,因此这里复用 core/ws.py 的帧实现。
用法:
./.venv/bin/python tests/ui_acceptance.py # 默认连 127.0.0.1:8787
./.venv/bin/python tests/ui_acceptance.py --url http://127.0.0.1:8787/ --out /tmp/ui
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import socket
import struct
import subprocess
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from urllib.parse import urlparse # noqa: E402
CHROME_CANDIDATES = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
]
PASS = 0
FAIL = 0
FAILURES: list[str] = []
def check(name: str, ok: bool, detail: str = "") -> None:
global PASS, FAIL
if ok:
PASS += 1
print(f" \033[32m✓\033[0m {name}")
else:
FAIL += 1
FAILURES.append(f"{name} {detail}")
print(f" \033[31m✗\033[0m {name} {detail}")
class CDP:
"""最小可用的 Chrome DevTools Protocol 客户端。"""
def __init__(self, ws_url: str, timeout: float = 30.0) -> None:
parsed = urlparse(ws_url)
self.sock = socket.create_connection((parsed.hostname or "127.0.0.1", parsed.port or 9222), timeout=timeout)
self.sock.settimeout(timeout)
key = base64.b64encode(os.urandom(16)).decode()
path = parsed.path + (("?" + parsed.query) if parsed.query else "")
request = (
f"GET {path} HTTP/1.1\r\nHost: {parsed.hostname}:{parsed.port}\r\n"
f"Upgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
)
self.sock.sendall(request.encode())
self.buf = b""
header = b""
while b"\r\n\r\n" not in header:
chunk = self.sock.recv(4096)
if not chunk:
raise RuntimeError("CDP 握手失败")
header += chunk
head, _, rest = header.partition(b"\r\n\r\n")
if b"101" not in head.split(b"\r\n")[0]:
raise RuntimeError(f"CDP 未升级:{head[:200]!r}")
self.buf = rest
self.counter = 0
self.timeout = timeout
def _recv(self, n: int) -> bytes:
while len(self.buf) < n:
chunk = self.sock.recv(65536)
if not chunk:
raise RuntimeError("CDP 连接关闭")
self.buf += chunk
out, self.buf = self.buf[:n], self.buf[n:]
return out
def _read_frame(self) -> str:
head = self._recv(2)
opcode = head[0] & 0x0F
length = head[1] & 0x7F
if length == 126:
length = struct.unpack("!H", self._recv(2))[0]
elif length == 127:
length = struct.unpack("!Q", self._recv(8))[0]
payload = self._recv(length) if length else b""
if opcode == 0x8:
raise RuntimeError("CDP 服务端关闭连接")
if opcode == 0x9: # ping → pong(带掩码)
mask = os.urandom(4)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
self.sock.sendall(bytes([0x8A, 0x80 | len(payload)]) + mask + masked)
return self._read_frame()
return payload.decode("utf-8", "replace")
def _send(self, obj: dict) -> None:
payload = json.dumps(obj).encode()
mask = os.urandom(4)
header = bytearray([0x81])
length = len(payload)
if length < 126:
header.append(0x80 | length)
elif length < 65536:
header.append(0x80 | 126)
header.extend(struct.pack("!H", length))
else:
header.append(0x80 | 127)
header.extend(struct.pack("!Q", length))
header.extend(mask)
self.sock.sendall(bytes(header) + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
def call(self, method: str, params: dict | None = None) -> dict:
self.counter += 1
message_id = self.counter
self._send({"id": message_id, "method": method, "params": params or {}})
while True:
data = json.loads(self._read_frame())
if data.get("id") == message_id:
if "error" in data:
raise RuntimeError(f"CDP {method} 失败:{data['error']}")
return data.get("result") or {}
# 其它事件(Runtime.consoleAPICalled 等)先记录到 self.events
self.events.append(data)
events: list = []
def eval(self, expression: str, await_promise: bool = False):
result = self.call("Runtime.evaluate", {
"expression": expression,
"returnByValue": True,
"awaitPromise": await_promise,
})
if result.get("exceptionDetails"):
detail = result["exceptionDetails"]
raise RuntimeError(f"页面 JS 异常:{detail.get('text')} "
f"{(detail.get('exception') or {}).get('description', '')[:300]}")
return (result.get("result") or {}).get("value")
def click(self, x: float, y: float, button: str = "left", clicks: int = 1) -> None:
for event_type in ("mousePressed", "mouseReleased"):
self.call("Input.dispatchMouseEvent", {
"type": event_type, "x": x, "y": y, "button": button,
"clickCount": clicks, "buttons": 1 if event_type == "mousePressed" else 0,
})
def move(self, x: float, y: float) -> None:
self.call("Input.dispatchMouseEvent", {"type": "mouseMoved", "x": x, "y": y, "buttons": 0})
def shot(self, path) -> None:
data = self.call("Page.captureScreenshot", {"format": "png"})
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(base64.b64decode(data["data"]))
def close(self) -> None:
try:
self.sock.close()
except OSError:
pass
def launch_chrome(width: int, height: int, port: int) -> subprocess.Popen:
chrome = next((p for p in CHROME_CANDIDATES if os.path.exists(p)), None)
if not chrome:
raise SystemExit("未找到 Chrome / Edge / Chromium")
profile = Path("/tmp/iosloc-ui-acceptance-profile")
subprocess.run(["rm", "-rf", str(profile)], check=False)
profile.mkdir(parents=True, exist_ok=True)
process = subprocess.Popen(
[
chrome, "--headless=new", "--disable-gpu", "--no-first-run",
"--no-default-browser-check", "--hide-scrollbars", "--disable-extensions",
f"--remote-debugging-port={port}", f"--user-data-dir={profile}",
f"--window-size={width},{height}", "about:blank",
],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True,
)
deadline = time.time() + 25
while time.time() < deadline:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=2) as response:
pages = json.loads(response.read())
for page in pages:
if page.get("type") == "page":
return process, page["webSocketDebuggerUrl"]
except Exception:
time.sleep(0.4)
process.terminate()
raise SystemExit("无法连接 Chrome 调试端口")
def main() -> int:
global PASS, FAIL
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="http://127.0.0.1:8787/")
parser.add_argument("--out", default="/tmp/ui-acceptance")
parser.add_argument("--port", type=int, default=9444)
parser.add_argument("--width", type=int, default=1440)
parser.add_argument("--height", type=int, default=900)
parser.add_argument("--keep-open", action="store_true", help="结束后不关闭 Chrome")
args = parser.parse_args()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
print("\033[1m图形界面验收(真实 Chrome 驱动)\033[0m")
process, ws_url = launch_chrome(args.width, args.height, args.port)
cdp = None
try:
cdp = CDP(ws_url)
cdp.call("Page.enable")
cdp.call("Runtime.enable")
cdp.call("Emulation.setDeviceMetricsOverride",
{"width": args.width, "height": args.height, "deviceScaleFactor": 2, "mobile": False})
cdp.call("Page.navigate", {"url": args.url})
time.sleep(3.5)
# ---- 0. 页面与服务端连通 ----
title = cdp.eval("document.title")
check("页面已加载", bool(title), str(title))
ws_state = cdp.eval("(function(){var d=document.querySelector('#connPill');return d?d.textContent.trim():''})()")
check("界面显示已连接服务端", "已连接" in str(ws_state), f"connPill={ws_state!r}")
device_text = cdp.eval("(function(){var d=document.querySelector('#deviceCard');return d?d.innerText.replace(/\\n/g,' | '):''})()")
check("设备卡片显示设备信息", "iPhone" in str(device_text) or "模拟" in str(device_text), str(device_text)[:120])
# ---- 1. 地图渲染 ----
map_info = cdp.eval(
"(function(){var c=document.querySelector('#map canvas');"
"var t=document.querySelectorAll('#map img').length;"
"return JSON.stringify({canvas:!!c,imgs:t,w:c?c.width:0,h:c?c.height:0})})()"
)
info = json.loads(map_info) if map_info else {}
check("地图画布已渲染", bool(info.get("w")) and bool(info.get("h")), str(info))
# ---- 2. 添加途经点:点工具条 + 在地图上点三个位置 ----
clicked_tool = cdp.eval(
"(function(){var b=document.querySelector('#toolAdd');if(!b)return 'no-btn';b.click();return b.className})()"
)
check("「添加途经点」按钮可点击", clicked_tool != "no-btn", str(clicked_tool))
time.sleep(0.4)
box = cdp.eval(
"(function(){var m=document.querySelector('#map');var r=m.getBoundingClientRect();"
"return JSON.stringify({x:r.x,y:r.y,w:r.width,h:r.height})})()"
)
rect = json.loads(box)
points = [
(rect["x"] + rect["w"] * 0.30, rect["y"] + rect["h"] * 0.35),
(rect["x"] + rect["w"] * 0.52, rect["y"] + rect["h"] * 0.55),
(rect["x"] + rect["w"] * 0.72, rect["y"] + rect["h"] * 0.42),
]
for (px, py) in points:
cdp.click(px, py)
time.sleep(0.45)
time.sleep(0.6)
wp_count = cdp.eval(
"(function(){var l=document.querySelectorAll('#wpList .wp-item, #wpList li, #wpList [data-index]');"
"return l.length})()"
)
check("地图点击添加了 3 个途经点", int(wp_count or 0) >= 3, f"列表条目={wp_count}")
cdp.shot(out_dir / "01-waypoints.png")
# ---- 3. 加载 OSRM 路线 ----
cdp.eval("(function(){var b=document.querySelector('#btnBuildRoute');if(b)b.click();return !!b})()")
route_ok = False
for _ in range(40):
time.sleep(0.5)
meta = cdp.eval("(function(){var d=document.querySelector('#statDistance');return d?d.textContent.trim():''})()")
if meta and meta not in ("—", "-", "0", ""):
route_ok = True
break
distance_text = cdp.eval("(function(){var d=document.querySelector('#statDistance');return d?d.textContent.trim():''})()")
check("「加载 OSRM 路线」生成了路线", route_ok, f"总距离={distance_text!r}")
engine_text = cdp.eval("(function(){var d=document.querySelector('#statEngine');return d?d.textContent.trim():''})()")
check("路线引擎已显示", bool(engine_text) and engine_text != "—", f"引擎={engine_text!r}")
cdp.shot(out_dir / "02-route.png")
# ---- 4. 播放(按钮是异步生效的:点击后等状态真正变为"播放中",必要时重试一次) ----
def click_play() -> None:
cdp.eval("(function(){var b=document.querySelector('#btnPlay, [data-action=play]');"
"if(b && !b.disabled)b.click();return !!b})()")
def wait_playing(timeout: float = 12.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
state = str(cdp.eval("(function(){var d=document.querySelector('#chipPlayState');return d?d.textContent.trim():''})()"))
if "播放中" in state or "已暂停" in state:
return True
time.sleep(0.3)
return False
click_play()
playing = wait_playing(12.0)
if not playing:
time.sleep(1.0)
click_play()
playing = wait_playing(12.0)
check("点击播放后进入播放态", playing,
str(cdp.eval("document.querySelector('#chipPlayState').textContent")))
progressed = False
progress_text = ""
coord_text = ""
for _ in range(40):
time.sleep(0.5)
progress_text = str(cdp.eval("(function(){var d=document.querySelector('#chipProgress');return d?d.textContent.trim():''})()"))
coord_text = str(cdp.eval("(function(){var d=document.querySelector('#chipCoord');return d?d.textContent.trim():''})()"))
if any(ch.isdigit() for ch in progress_text) and "0%" not in progress_text and "0 %" not in progress_text:
progressed = True
break
check("播放推进(地图 chips 显示进度)", progressed, f"chipProgress={progress_text!r}")
check("实时坐标已显示", any(ch.isdigit() for ch in coord_text), f"chipCoord={coord_text!r}")
play_state = cdp.eval("(function(){var d=document.querySelector('#chipPlayState');return d?d.textContent.trim():''})()")
check("播放状态文案正常", bool(play_state) and "未播放" not in str(play_state), f"chipPlayState={play_state!r}")
cdp.shot(out_dir / "03-playing.png")
# ---- 5. 暂停 / 继续 / 停止(暂停是异步推送,采样窗口内看是否出现过"已暂停") ----
cdp.eval("(function(){var b=document.querySelector('#btnPause, [data-action=pause]');if(b)b.click();return !!b})()")
paused_seen = False
paused_sample = ""
for _ in range(20):
time.sleep(0.15)
paused_sample = str(cdp.eval("(function(){var d=document.querySelector('#chipPlayState');return d?d.textContent.trim():''})()"))
if "暂停" in paused_sample:
paused_seen = True
break
check("暂停后状态文案变化", paused_seen, f"{paused_sample!r}")
cdp.eval("(function(){var b=document.querySelector('#btnResume, [data-action=resume]');if(b)b.click();return !!b})()")
time.sleep(1.0)
resumed_text = cdp.eval("(function(){var d=document.querySelector('#chipPlayState');return d?d.textContent.trim():''})()")
check("继续后恢复播放", "暂停" not in str(resumed_text) or "播放" in str(resumed_text), f"{resumed_text!r}")
cdp.eval("(function(){var b=document.querySelector('#btnStop, [data-action=stop]');if(b)b.click();return !!b})()")
time.sleep(1.5)
stop_text = cdp.eval("(function(){var d=document.querySelector('#chipPlayState');return d?d.textContent.trim():''})()")
check("停止后状态回到未播放", "未播放" in str(stop_text) or "已停止" in str(stop_text), f"{stop_text!r}")
cdp.shot(out_dir / "04-stopped.png")
# ---- 6. 底图切换 ----
options = cdp.eval(
"(function(){var s=document.querySelector('#tileSourceSelect');"
"return JSON.stringify(Array.from(s.options).map(function(o){return o.value}))})()"
)
opts = json.loads(options or "[]")
check("底图下拉有多个瓦片源", len(opts) >= 3, str(opts))
if len(opts) >= 2:
cdp.eval(f"(function(){{var s=document.querySelector('#tileSourceSelect');"
f"s.value={json.dumps(opts[1])};s.dispatchEvent(new Event('change',{{bubbles:true}}));return s.value}})()")
time.sleep(3.0)
current = cdp.eval("(function(){var s=document.querySelector('#tileSourceSelect');return s.value})()")
check("切换底图生效", current == opts[1], f"{current!r} vs {opts[1]!r}")
cdp.shot(out_dir / "05-tilesource.png")
# ---- 7. 日志面板(先展开再看内容) ----
cdp.eval("(function(){var b=document.querySelector('#btnToggleLog');if(b)b.click();return !!b})()")
time.sleep(1.2)
log_chars = cdp.eval("(function(){var p=document.querySelector('#logBody');return p?(p.innerText||'').length:0})()")
check("日志面板能展开且有内容", int(log_chars or 0) > 30, f"字符数={log_chars}")
log_text = cdp.eval("(function(){var p=document.querySelector('#logBody');return p?(p.innerText||'').slice(0,200):''})()")
check("日志内容为中文可读", bool(log_text) and any("\u4e00" <= ch <= "\u9fff" for ch in str(log_text)),
str(log_text)[:100])
cdp.shot(out_dir / "06-logs.png")
# ---- 8. 收集页面控制台报错 ----
errors = []
for event in cdp.events:
method = event.get("method")
if method == "Runtime.exceptionThrown":
detail = event["params"]["exceptionDetails"]
errors.append(f"{detail.get('text')} {(detail.get('exception') or {}).get('description', '')[:200]}")
elif method == "Runtime.consoleAPICalled" and event["params"].get("type") == "error":
parts = [str(a.get("value", a.get("description", "")))[:160] for a in event["params"].get("args", [])]
errors.append("console.error: " + " ".join(parts))
check("页面无 JS 报错", not errors, " | ".join(errors[:4]))
if errors:
print("\n 页面报错明细:")
for item in errors[:10]:
print(" -", item[:240])
except Exception as exc:
FAIL += 1
FAILURES.append(f"验收脚本异常 {exc}")
print(f" \033[31m✗\033[0m 验收脚本异常:{exc.__class__.__name__}: {exc}")
import traceback
traceback.print_exc()
finally:
if cdp:
cdp.close()
if not args.keep_open:
try:
os.killpg(os.getpgid(process.pid), 15)
except Exception:
process.terminate()
print(f"\n{'=' * 56}")
print(f"图形界面验收:通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项")
print(f"截图目录:{out_dir}")
if FAILURES:
print("失败明细:")
for item in FAILURES:
print(" -", item[:220])
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())