同一个用户站在门店边缘,应用却连续记录“进入、离开、进入”。先别调整定位频率。把距离计算、精度过滤和状态切换拆开,在云端 Mac 上用固定样本重放,才能判断是业务规则抖动,还是系统事件与实际位置不一致。
下面验收的是应用自己的围栏策略,不是替代系统区域监测。全程使用离线坐标,不采集用户轨迹,也不依赖远程机器所在地。
先写清进入与离开的契约
示例规定:距离中心不超过 80 米才进入;达到 120 米才离开;中间区域保持原状态。定位水平精度必须在 0 至 30 米之间,否则本次不更新状态。这些数字是测试策略,不是通用产品参数。
还要区分“没有可信样本”和“确定在外面”。首次启动用未知状态,不能为了让界面有值就默认为已离开。负数精度、非有限数及越界坐标都应拒绝。
水平精度是测量质量信息,不是用户实际偏移距离。不要把它直接从距离中减掉,再宣称用户一定在围栏里。
在独立包中固定距离计算
先在云端 Mac 的普通工作目录创建测试包:
mkdir GeoPolicy
cd GeoPolicy
swift package init --type library
将 Sources/GeoPolicy/GeoPolicy.swift 替换为以下实现。球面距离使用米,角度先转弧度;三角函数可以正确处理跨日期变更线的经度差,不要直接把经纬度差当平面距离。
import Foundation
struct Point {
let latitude: Double
let longitude: Double
var valid: Bool {
latitude.isFinite && longitude.isFinite &&
(-90...90).contains(latitude) &&
(-180...180).contains(longitude)
}
}
func meters(_ a: Point, _ b: Point) -> Double? {
guard a.valid, b.valid else { return nil }
let radians = Double.pi / 180
let p1 = a.latitude * radians
let p2 = b.latitude * radians
let dp = (b.latitude - a.latitude) * radians
let dl = (b.longitude - a.longitude) * radians
let h = pow(sin(dp / 2), 2) +
cos(p1) * cos(p2) * pow(sin(dl / 2), 2)
return 6_371_000 * 2 * asin(sqrt(min(1, max(0, h))))
}
这是球面近似,适合本文的策略测试,不是测绘级算法。如果生产代码使用系统距离接口,测试应调用同一接口;不要只验证这份辅助实现,却让应用运行另一套公式。
用三态与双阈值消除边界抖动
继续添加策略函数。未知状态遇到中间带样本仍然未知;已经在内或在外时,中间带只保留旧值。无效测量不触发状态转移,也不应触发到店奖励之类的业务副作用。
enum FenceState: Equatable {
case unknown, outside, inside
}
func nextState(
previous: FenceState,
distance: Double,
accuracy: Double
) -> FenceState {
guard distance.isFinite, distance >= 0,
accuracy.isFinite, (0...30).contains(accuracy)
else { return previous }
if distance <= 80 { return .inside }
if distance >= 120 { return .outside }
return previous
}
真实接入时先校验样本时间:过期位置不能覆盖更新的位置。时间校验放在适配层,并注入当前时间测试,不要在纯策略里直接读取系统时钟。精度为零在接口约定中不属于负值无效标记;是否接受它,应另有明确业务依据。
让测试覆盖边界而不依赖定位等待
将 Tests/GeoPolicyTests/GeoPolicyTests.swift 替换为下面的测试。状态边界直接使用距离值,避免坐标换算误差掩盖等号语义;距离函数则单独验证。
import XCTest
@testable import GeoPolicy
final class GeoPolicyTests: XCTestCase {
func testDistanceAcrossDateLine() throws {
let a = Point(latitude: 0, longitude: 179.999)
let b = Point(latitude: 0, longitude: -179.999)
let distance = try XCTUnwrap(meters(a, b))
XCTAssertEqual(distance, 222.39, accuracy: 0.5)
XCTAssertEqual(
distance,
try XCTUnwrap(meters(b, a)),
accuracy: 0.001
)
XCTAssertEqual(try XCTUnwrap(meters(a, a)), 0)
XCTAssertNil(meters(
Point(latitude: 91, longitude: 0), b
))
}
func testHysteresisAndRejectedFixes() {
var state = FenceState.outside
let inputs = [79.0, 85, 110, 119.9, 120, 100, 80]
let expected: [FenceState] = [
.inside, .inside, .inside, .inside,
.outside, .outside, .inside
]
for (distance, result) in zip(inputs, expected) {
state = nextState(
previous: state, distance: distance, accuracy: 10
)
XCTAssertEqual(state, result)
}
for accuracy in [-1.0, 30.01, .nan, .infinity] {
XCTAssertEqual(nextState(
previous: .outside, distance: 20, accuracy: accuracy
), .outside)
}
XCTAssertEqual(nextState(
previous: .unknown, distance: 100, accuracy: 10
), .unknown)
XCTAssertEqual(nextState(
previous: .outside, distance: 80, accuracy: 30
), .inside)
}
}
运行 swift test。这里的 222.39 米来自示例坐标与球面公式,不是节点网络或设备实测数据。后续补入负距离、无穷距离、极区坐标和对跖点样本;数值误差用明确容差表达,不要比较浮点字符串。
把规则测试接回应用流水线
先让纯逻辑测试成为合并前检查,再在 iOS 测试目标里覆盖定位适配层:旧样本被丢弃、授权撤销后状态如何呈现、无效样本是否只记录原因。业务通知只在有效状态发生变化时发出,重复的内部状态不能重复记账。
保留样本序号、距离、精度、旧状态、新状态和拒绝原因即可;日常失败报告不需要保存完整真实坐标。固定样本与策略版本一起提交,修改半径时同步审查预期结果。
最后单独做真机验收:系统围栏回调、后台唤醒和实际定位误差不是这组离线测试的覆盖范围。验收结果应分开记录“规则通过”和“设备行为通过”,不要用一次模拟器运行替代两者。
常见问题
围栏边缘为什么不能只用一个距离阈值?
定位误差会使连续样本在同一阈值两侧跳动。进入使用较小阈值、退出使用较大阈值,中间区域保留原状态,可以减少重复切换。
这些测试通过后,还需要真机验收吗?
需要。离线测试只覆盖业务距离与状态规则,系统围栏事件、后台唤醒、授权变化和实际定位质量仍须分别在真机上验收。