Тестируем геозоны iOS: расстояние, точность и гистерезис

Тестируем геозоны iOS: расстояние, точность и гистерезис

Тестируем геозоны iOS: расстояние, точность и гистерезис

Пользователь стоит у границы магазина, а приложение раз за разом регистрирует «вход, выход, вход». Не спешите менять частоту определения местоположения. Сначала разделите расчёт расстояния, фильтрацию по точности и переходы между состояниями, а затем воспроизведите фиксированный набор образцов на облачном 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))))
}

Это сферическое приближение, подходящее для проверки логики в этой статье, а не алгоритм геодезической точности. Если рабочий код использует системный API расчёта расстояния, тесты должны вызывать тот же API. Не ограничивайтесь проверкой вспомогательной реализации, если в приложении применяется другая формула.

Устраните дребезг на границе с помощью трёх состояний и двух порогов

Теперь добавьте функцию принятия решения. Если состояние неизвестно и образец попадает в промежуточную полосу, состояние остаётся неизвестным. Если уже известно, что пользователь внутри или снаружи, промежуточная полоса лишь сохраняет прежнее значение. Некорректное измерение не вызывает перехода состояния и не должно запускать побочные бизнес-действия, например начисление бонуса за посещение магазина.

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
}

При интеграции сначала проверяйте время измерения: устаревшая позиция не должна перезаписывать более свежую. Проверку времени разместите в слое адаптации и передавайте текущее время как зависимость для тестирования, а не считывайте системные часы прямо в чистой логике принятия решений. По контракту API нулевая точность не относится к отрицательным значениям, обозначающим недействительное измерение; принимать ли её, должно определяться отдельным явным бизнес-правилом.

Проверьте границы без ожидания геолокации

Замените содержимое 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: отбрасываются ли старые измерения, как отображается состояние после отзыва разрешения и ограничивается ли обработка некорректного измерения записью причины отклонения. Бизнес-уведомления отправляйте только при изменении состояния по допустимому измерению; повторное получение того же внутреннего состояния не должно приводить к повторным начислениям.

Достаточно сохранять порядковый номер образца, расстояние, точность, старое и новое состояния, а также причину отклонения. В обычных отчётах о сбоях полные реальные координаты не нужны. Фиксированные образцы храните в репозитории вместе с версией правил, а при изменении радиусов одновременно пересматривайте ожидаемые результаты.

В завершение отдельно проведите приёмочные испытания на реальном устройстве: системные обратные вызовы геозон, пробуждение в фоне и фактическая погрешность геолокации не входят в область этих офлайн-тестов. В результатах отдельно отмечайте «правила проверены» и «поведение устройства проверено». Один запуск в симуляторе не заменяет обе проверки.

Часто задаваемые вопросы

Почему одного порога расстояния недостаточно?

Из-за погрешности координаты могут постоянно пересекать один порог. Меньший порог входа и больший порог выхода сохраняют прежнее состояние между ними.

Заменяют ли эти тесты проверку на устройстве?

Нет. Они проверяют бизнес-логику. Системные события геозон, фоновое пробуждение, разрешения и реальную точность нужно проверять на устройстве.

Выделенный облачный Mac для сборок

Вся физическая машина — только в вашем распоряжении, без виртуализации. Выберите модель и срок аренды под объём сборок, а архивы, подписи и кэш храните на своём узле.

Выбрать облачный Mac

Следующая сборка — на выделенном облачном Mac

Выберите машину под объём сборок, а узел — с учётом расположения команды. Сначала сравните тарифы, затем перенесите инструменты.