iOS/Tests/App/ZoneManager/ZoneManager.test.swift

540 lines
19 KiB
Swift

import CoreLocation
import Foundation
@testable import HomeAssistant
import PromiseKit
import RealmSwift
@testable import Shared
import XCTest
class ZoneManagerTests: XCTestCase {
private var realm: Realm!
private var collector: FakeCollector!
private var processor: FakeProcessor!
private var regionFilter: FakeRegionFilter!
private var locationManager: FakeCLLocationManager!
private var apis: [FakeHassAPI]!
private var loggedEventsUpdatedExpectation: XCTestExpectation?
private var loggedEvents: [ClientEvent]! {
didSet {
loggedEventsUpdatedExpectation?.fulfill()
}
}
enum TestError: Error {
case anyError
}
override func setUpWithError() throws {
try super.setUpWithError()
Current.settingsStore.locationSources.zone = true
Current.settingsStore.locationSources.significantLocationChange = true
let executionIdentifier = UUID().uuidString
realm = try Realm(configuration: .init(inMemoryIdentifier: executionIdentifier))
let servers = FakeServerManager(initial: 2)
let server1 = servers.all[0]
let server2 = servers.all[1]
apis = [FakeHassAPI(server: server1), FakeHassAPI(server: server2)]
Current.servers = servers
Current.cachedApis = [server1.identifier: apis[0], server2.identifier: apis[1]]
loggedEvents = []
Current.connectivity.currentWiFiSSID = { "wifi_name" }
Current.realm = { self.realm }
Current.clientEventStore.addEvent = { self.loggedEvents.append($0); return .value(()) }
Current.location.oneShotLocation = { _, _ in .value(.init(latitude: 0, longitude: 0)) }
collector = FakeCollector()
processor = FakeProcessor()
regionFilter = FakeRegionFilter()
locationManager = FakeCLLocationManager()
}
override func tearDown() {
super.tearDown()
Current.realm = Realm.live
Current.clientEventStore.addEvent = { _ in .value(()) }
}
private func newZoneManager() -> ZoneManager {
ZoneManager(
locationManager: locationManager,
collector: collector,
processor: processor,
regionFilter: regionFilter
)
}
private func addedZones(_ toAdd: [RLMZone]) throws -> [RLMZone] {
try realm.write {
realm.add(toAdd)
return toAdd
}
}
func testStartingWithNoRegionsAddsFromRealm() throws {
var removedRegions = [CLRegion]()
var addedRegions = [CLRegion]()
var zones = try addedZones([
with(RLMZone()) {
$0.entityId = "home"
$0.serverIdentifier = apis[0].server.identifier.rawValue
$0.Latitude = 37.1234
$0.Longitude = -122.4567
$0.Radius = 50.0
$0.TrackingEnabled = true
$0.BeaconUUID = UUID().uuidString
$0.BeaconMajor.value = 123
$0.BeaconMinor.value = 456
},
with(RLMZone()) {
$0.entityId = "work"
$0.serverIdentifier = apis[1].server.identifier.rawValue
$0.Latitude = 37.2345
$0.Longitude = -122.5678
$0.Radius = 100
$0.TrackingEnabled = true
},
])
var currentRegions: Set<CLRegion> {
Set(zones.flatMap(\.regionsForMonitoring))
}
let manager = newZoneManager()
addedRegions.append(contentsOf: zones.flatMap(\.regionsForMonitoring))
XCTAssertEqual(
locationManager.startMonitoringRegions.hackilySorted(),
addedRegions.hackilySorted()
)
// mutate a zone
try realm.write {
removedRegions.append(contentsOf: zones[1].regionsForMonitoring)
zones[1].Latitude += 0.02
addedRegions.append(contentsOf: zones[1].regionsForMonitoring)
}
realm.refresh()
XCTAssertEqual(locationManager.monitoredRegions, currentRegions)
XCTAssertEqual(locationManager.stopMonitoringRegions.hackilySorted(), removedRegions.hackilySorted())
XCTAssertEqual(locationManager.startMonitoringRegions.hackilySorted(), addedRegions.hackilySorted())
XCTAssertEqual(collector.ignoringNextStates, Set(addedRegions))
// remove a zone
try realm.write {
let toRemove = zones.popLast()!
removedRegions.append(contentsOf: toRemove.regionsForMonitoring)
realm.delete(toRemove)
}
realm.refresh()
XCTAssertEqual(locationManager.monitoredRegions, currentRegions)
XCTAssertEqual(locationManager.stopMonitoringRegions.hackilySorted(), removedRegions.hackilySorted())
XCTAssertEqual(locationManager.startMonitoringRegions.hackilySorted(), addedRegions.hackilySorted())
XCTAssertEqual(collector.ignoringNextStates, Set(addedRegions))
withExtendedLifetime(manager) { /* silences unused variable */ }
}
func testStartingWithZoneButNoneWanted() throws {
let startRegion = CLCircularRegion(
center: .init(latitude: 12.456, longitude: 67.890),
radius: 45,
identifier: "abc"
)
locationManager.overrideMonitoredRegions.insert(startRegion)
XCTAssertFalse(locationManager.monitoredRegions.isEmpty)
let manager = newZoneManager()
XCTAssertEqual(locationManager.stopMonitoringRegions, [startRegion])
XCTAssertTrue(locationManager.monitoredRegions.isEmpty)
realm.refresh()
XCTAssertEqual(locationManager.stopMonitoringRegions, [startRegion])
XCTAssertTrue(locationManager.monitoredRegions.isEmpty)
withExtendedLifetime(manager) { /* silences unused variable */ }
}
func testTrackingDisabledNotMonitored() throws {
let s1: String = apis[0].server.identifier.rawValue
let s2: String = apis[1].server.identifier.rawValue
let zones = try addedZones([
with(RLMZone()) {
$0.entityId = "home"
$0.serverIdentifier = s1
$0.Latitude = 37.1234
$0.Longitude = -122.4567
$0.Radius = 100
$0.TrackingEnabled = false
},
with(RLMZone()) {
$0.entityId = "work"
$0.serverIdentifier = s2
$0.Latitude = 37.2345
$0.Longitude = -122.5678
$0.Radius = 150
$0.TrackingEnabled = true
},
])
let manager = newZoneManager()
XCTAssertEqual(Set(locationManager.monitoredRegions.map(\.identifier)), Set(["\(s2)/work"]))
try realm.write {
zones[0].TrackingEnabled = true
}
realm.refresh()
XCTAssertEqual(Set(locationManager.monitoredRegions.map(\.identifier)), Set(["\(s2)/work", "\(s1)/home"]))
try realm.write {
zones[1].TrackingEnabled = false
}
realm.refresh()
XCTAssertEqual(Set(locationManager.monitoredRegions.map(\.identifier)), Set(["\(s1)/home"]))
withExtendedLifetime(manager) { /* silences unused variable */ }
}
func testFilterChangesOnLocationChange() throws {
let zones = try addedZones([
with(RLMZone()) {
$0.entityId = "home"
$0.serverIdentifier = apis[0].server.identifier.rawValue
$0.Latitude = 37.1234
$0.Longitude = -122.4567
$0.Radius = 50.0
$0.TrackingEnabled = true
$0.BeaconUUID = UUID().uuidString
$0.BeaconMajor.value = 123
$0.BeaconMinor.value = 456
},
with(RLMZone()) {
$0.entityId = "work"
$0.serverIdentifier = apis[1].server.identifier.rawValue
$0.Latitude = 37.2345
$0.Longitude = -122.5678
$0.Radius = 100
$0.TrackingEnabled = true
},
])
XCTAssertEqual(locationManager.monitoredRegions.count, 0)
let manager = newZoneManager()
XCTAssertEqual(locationManager.monitoredRegions.count, 2)
let expectedReplacement = CLCircularRegion(
center: .init(latitude: 3.33, longitude: 4.44),
radius: 100,
identifier: "replaced"
)
regionFilter.regionsBlock = {
AnyCollection([expectedReplacement])
}
processor.promiseToReturn = .value(())
let expectation = expectation(description: "promise")
expectation.assertForOverFulfill = false // changing zones adds logs and we don't care
loggedEventsUpdatedExpectation = expectation
manager.collector(collector, didCollect: .init(
eventType: .locationChange([CLLocation(latitude: 1.23, longitude: 4.56)])
))
let expectation2 = self.expectation(
for: .init(format: "monitoredRegions.@count == 1"),
evaluatedWith: locationManager,
handler: nil
)
wait(for: [expectation, expectation2], timeout: 10)
XCTAssertEqual(locationManager.monitoredRegions.count, 1)
XCTAssertEqual(locationManager.monitoredRegions, Set([expectedReplacement]))
XCTAssertEqual(regionFilter.lastAskedZones.flatMap { Set($0) }, Set(zones))
}
func testBasicStartup() {
let manager = newZoneManager()
XCTAssertTrue(locationManager.isMonitoringSigLocChanges)
XCTAssertTrue(locationManager.delegate === manager.collector)
XCTAssertTrue(locationManager.delegate === collector)
XCTAssertTrue(locationManager.allowsBackgroundLocationUpdates)
XCTAssertFalse(locationManager.pausesLocationUpdatesAutomatically)
}
func testLocationUpdateSource() throws {
let zones = try addedZones([
with(RLMZone()) {
$0.entityId = "home"
$0.serverIdentifier = apis[0].server.identifier.rawValue
$0.Latitude = 37.1234
$0.Longitude = -122.4567
$0.Radius = 50.0
$0.TrackingEnabled = true
$0.BeaconUUID = UUID().uuidString
$0.BeaconMajor.value = 123
$0.BeaconMinor.value = 456
},
with(RLMZone()) {
$0.entityId = "work"
$0.serverIdentifier = apis[1].server.identifier.rawValue
$0.Latitude = 37.2345
$0.Longitude = -122.5678
$0.Radius = 100
$0.TrackingEnabled = true
},
])
Current.settingsStore.locationSources.zone = false
Current.settingsStore.locationSources.significantLocationChange = false
let manager = newZoneManager()
XCTAssertFalse(locationManager.isMonitoringSigLocChanges)
XCTAssertEqual(locationManager.requestedRegions.count, 0)
Current.settingsStore.locationSources.significantLocationChange = true
XCTAssertTrue(locationManager.isMonitoringSigLocChanges)
XCTAssertEqual(locationManager.requestedRegions.count, 0)
Current.settingsStore.locationSources.zone = true
XCTAssertTrue(locationManager.isMonitoringSigLocChanges)
XCTAssertEqual(locationManager.startMonitoringRegions.count, zones.flatMap(\.regionsForMonitoring).count)
withExtendedLifetime(manager) {
// for managing the location manager
}
}
func testCollectorCollectsSingleRegionZoneAndEventFires() throws {
let manager = newZoneManager()
let api = apis[1]
let region = CLCircularRegion(
center: .init(latitude: 42.4242, longitude: 43.4343),
radius: 456,
identifier: "dogs"
)
let zone = try addedZones([
with(RLMZone()) {
$0.entityId = "zone.zid"
$0.serverIdentifier = api.server.identifier.rawValue
$0.Latitude = 42.2222
$0.Longitude = 43.3333
$0.Radius = 100
$0.TrackingEnabled = true
},
])[0]
processor.promiseToReturn = .value(())
api.resetCreatedEventInfo()
manager.collector(collector, didCollect: ZoneManagerEvent(
eventType: .region(region, .inside),
associatedZone: zone
))
let createdEvent1 = try hang(api.createdEventPromise)
XCTAssertEqual(createdEvent1.eventType, "ios.zone_entered")
XCTAssertEqual(createdEvent1.eventData["zone"] as? String, "zone.zid")
api.resetCreatedEventInfo()
manager.collector(collector, didCollect: ZoneManagerEvent(
eventType: .region(region, .outside),
associatedZone: zone
))
let createdEvent2 = try hang(api.createdEventPromise)
XCTAssertEqual(createdEvent2.eventType, "ios.zone_exited")
XCTAssertEqual(createdEvent2.eventData["zone"] as? String, "zone.zid")
}
func testCollectorCollectsMultipleRegionZoneAndEventFires() throws {
let manager = newZoneManager()
let api = apis[1]
let region = CLCircularRegion(
center: .init(latitude: 42.4242, longitude: 43.4343),
radius: 456,
identifier: "zone.zid@868"
)
let zone = try addedZones([
with(RLMZone()) {
$0.entityId = "zone.zid"
$0.serverIdentifier = api.server.identifier.rawValue
$0.Latitude = 42.2222
$0.Longitude = 43.3333
$0.Radius = 99
$0.TrackingEnabled = true
},
])[0]
processor.promiseToReturn = .value(())
api.resetCreatedEventInfo()
manager.collector(collector, didCollect: ZoneManagerEvent(
eventType: .region(region, .inside),
associatedZone: zone
))
let createdEvent1 = try hang(api.createdEventPromise)
XCTAssertEqual(createdEvent1.eventType, "ios.zone_entered")
XCTAssertEqual(createdEvent1.eventData["zone"] as? String, "zone.zid")
XCTAssertEqual(createdEvent1.eventData["multi_region_zone_id"] as? String, "868")
api.resetCreatedEventInfo()
manager.collector(collector, didCollect: ZoneManagerEvent(
eventType: .region(region, .outside),
associatedZone: zone
))
let createdEvent2 = try hang(api.createdEventPromise)
XCTAssertEqual(createdEvent2.eventType, "ios.zone_exited")
XCTAssertEqual(createdEvent2.eventData["zone"] as? String, "zone.zid")
XCTAssertEqual(createdEvent2.eventData["multi_region_zone_id"] as? String, "868")
}
func testCollectorCollectsEventAndProcessorErrors() {
let manager = newZoneManager()
let region = CLCircularRegion(
center: .init(latitude: 42.4242, longitude: 43.4343),
radius: 456,
identifier: "dogs"
)
let event = ZoneManagerEvent(eventType: .region(region, .inside), associatedZone: nil)
let (promise, seal) = Promise<Void>.pending()
processor.promiseToReturn = promise
manager.collector(manager.collector, didCollect: event)
XCTAssertEqual(processor.performEvent, event)
XCTAssertTrue(loggedEvents.isEmpty)
seal.reject(TestError.anyError)
let expectation = expectation(description: "promise")
loggedEventsUpdatedExpectation = expectation
seal.fulfill(())
wait(for: [expectation], timeout: 10)
guard let loggedEvent = loggedEvents.first else {
return
}
XCTAssertTrue(loggedEvent.type == .locationUpdate)
XCTAssertTrue(loggedEvent.text.contains("Didn't update"))
XCTAssertEqual(loggedEvent.jsonPayload?["start_ssid"] as? String, "wifi_name")
XCTAssertEqual(loggedEvent.jsonPayload?["event"] as? String, event.description)
}
func testCollectorCollectsEventAndProcessorSucceeds() {
let manager = newZoneManager()
let region = CLCircularRegion(
center: .init(latitude: 42.4242, longitude: 43.4343),
radius: 456,
identifier: "dogs"
)
let event = ZoneManagerEvent(eventType: .region(region, .inside), associatedZone: nil)
let (promise, seal) = Promise<Void>.pending()
processor.promiseToReturn = promise
manager.collector(manager.collector, didCollect: event)
XCTAssertEqual(processor.performEvent, event)
XCTAssertTrue(loggedEvents.isEmpty)
let expectation = expectation(description: "promise")
loggedEventsUpdatedExpectation = expectation
seal.fulfill(())
wait(for: [expectation], timeout: 10)
XCTAssertTrue(loggedEvents.count == 1)
guard let loggedEvent = loggedEvents.first else {
return
}
XCTAssertTrue(loggedEvent.type == .locationUpdate)
XCTAssertTrue(loggedEvent.text.contains("Updated location"))
XCTAssertEqual(loggedEvent.jsonPayload?["start_ssid"] as? String, "wifi_name")
XCTAssertEqual(loggedEvent.jsonPayload?["event"] as? String, event.description)
}
}
private extension Array where Element: CLRegion {
func hackilySorted() -> [CLRegion] {
sorted(by: { $0.identifier < $1.identifier })
}
}
private class FakeCollector: NSObject, ZoneManagerCollector {
var delegate: ZoneManagerCollectorDelegate?
var ignoringNextStates = Set<CLRegion>()
func ignoreNextState(for region: CLRegion) {
ignoringNextStates.insert(region)
}
}
private class FakeProcessor: ZoneManagerProcessor {
var delegate: ZoneManagerProcessorDelegate?
var promiseToReturn: Promise<Void>?
var performEvent: ZoneManagerEvent?
func perform(event: ZoneManagerEvent) -> Promise<Void> {
performEvent = event
return promiseToReturn!
}
}
private class FakeRegionFilter: ZoneManagerRegionFilter {
var lastAskedZones: AnyCollection<RLMZone>?
var regionsBlock: (() -> AnyCollection<CLRegion>)?
func regions(
from zones: AnyCollection<RLMZone>,
currentRegions: AnyCollection<CLRegion>,
lastLocation: CLLocation?
) -> AnyCollection<CLRegion> {
lastAskedZones = zones
if let regionsBlock {
return regionsBlock()
} else {
return AnyCollection(zones.flatMap(\.regionsForMonitoring))
}
}
}
private class FakeHassAPI: HomeAssistantAPI {
typealias CreatedEventInfo = (eventType: String, eventData: [String: Any])
func resetCreatedEventInfo() {
(createdEventPromise, createdEventSeal) = Promise<CreatedEventInfo>.pending()
}
var createdEventPromise: Promise<CreatedEventInfo>!
var createdEventSeal: Resolver<CreatedEventInfo>?
override func CreateEvent(eventType: String, eventData: [String: Any]) -> Promise<Void> {
createdEventSeal?.fulfill((eventType: eventType, eventData: eventData))
return .value(())
}
}